From 28e18d9b516ada1338aa9266469c22b18dab0749 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 13:14:43 -0400 Subject: [PATCH 01/17] =?UTF-8?q?docs:=20plano=20da=20limpeza=20remota=20e?= =?UTF-8?q?=20rota=C3=A7=C3=A3o=20da=20chave=20do=20cache=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...issue-81-wipe-remoto-e-rotacao-de-chave.md | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-17-issue-81-wipe-remoto-e-rotacao-de-chave.md diff --git a/docs/superpowers/plans/2026-10-17-issue-81-wipe-remoto-e-rotacao-de-chave.md b/docs/superpowers/plans/2026-10-17-issue-81-wipe-remoto-e-rotacao-de-chave.md new file mode 100644 index 0000000..b95147f --- /dev/null +++ b/docs/superpowers/plans/2026-10-17-issue-81-wipe-remoto-e-rotacao-de-chave.md @@ -0,0 +1,304 @@ +# Issue #81 — Wipe remoto do aparelho do ACS e rotação da chave do cache (R3, CRIPTO-APP-CACHE) — 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:** Um coordenador (só ACS da UBS dele) ou o administrador manda pelo backoffice limpar um aparelho do ACS. O aparelho perde o acesso na hora, apaga no próximo contato com o servidor tudo o que o SinalACS guarda nele, incluindo as visitas não enviadas, e troca a chave do SQLCipher. O servidor registra o pedido, a entrega e a confirmação na trilha. + +**Architecture:** O pedido de limpeza é uma linha nova em `device_wipe_orders`, uma por `deviceId` (índice único). O id do aparelho já existe: é o UUID da instalação que o app manda no login, e ele fica em `acs_refresh_tokens.deviceId` e `acs_upload_tokens.deviceId`. Na mesma transação do pedido, o servidor revoga todos os refresh tokens e tokens de envio desse `deviceId`. O aparelho descobre o pedido por três caminhos, e os três chegam ao mesmo `RemoteWipeCoordinator`: +- `devices.pendingWipe(deviceId)`, um método público sem token, chamado na partida, na volta do segundo plano e quando a conexão volta; +- `DeviceWipeRequiredException` em `auth.refreshSession`, a cada 15 min no máximo; +- a mesma exceção em `auth.loginInstitutional`. + +O coordenador grava um diário (`WipeJournal`), apaga o banco, a cópia `.recuperado`, a chave, os tokens e o id do aparelho, e depois confirma com `devices.confirmWipe`. A rotação da chave (CRIPTO-APP-CACHE) acontece por evento: o wipe remoto e o "Limpar este aparelho" passam a destruir o arquivo e a chave. A abertura seguinte cria uma chave nova. + +**Tech Stack:** Serverpod 3.4.13 (Dart) + Postgres (migração nova); Flutter `apps/acs` e `apps/admin` com o `sinalacs_client` gerado; bash + `adb` + `e2e_stack.sh` + `otp_relay.py` para a prova no `emulator-5554`. + +**Spec:** issue #81 (`gh issue view 81`); `docs/auditoria-prd/2026-10-15-lacunas-prd.md` (linhas R3, STRIDE-S, CRIPTO-APP-CACHE e item 7 do backlog); `spec/PRD_system.md` §6.3 (R3: "SQLCipher + chave derivada de biometria; remote wipe via API"; contingência: "1. Remote wipe via API 2. Rotação de chaves imediata", recuperação em 15 min); `spec/lgpd_design.md` §5.1 (custódia da chave). + +## Global Constraints + +- **Escopo decidido com o dono (2026-10-17):** wipe remoto + rotação da chave nesta issue. O certificado de cliente por aparelho (STRIDE-S) fica **fora**: vira uma decisão registrada no `PROGRESS.md` e uma issue de continuação (Task 8). Motivos: precisa de uma lib X.509/CSR fora do stack, de uma CA de aparelhos e de PKI de produção. +- Escopo do operador igual ao da #43: o coordenador só alcança ACS da própria UBS e o admin alcança todos (`AdminScopeResolver`, recurso `admin_acs`). Fora do escopo e inexistente recebem a **mesma** recusa (`AdminInvalidRequestException('Aparelho não encontrado.')`), com uma linha `denied` gravada antes de lançar. +- Um aparelho é "do ACS" só se existe linha em `acs_refresh_tokens` **ou** em `acs_upload_tokens` com `userId = acsId` e aquele `deviceId`. O sentinela `InstitutionalAuthService.deviceIdAbsent` nunca é listado nem aceito. +- O pedido é irreversível: não existe cancelamento (D4). Repetir o pedido é idempotente e devolve `alreadyOrdered: true` com o `requestedAt` original. +- Motivo de um conjunto fechado: enum `DeviceWipeReason { lost, stolen, replaced }`. Rótulos no admin: "Perdido", "Roubado", "Substituído". +- Os dois métodos públicos (`devices.pendingWipe` e `devices.confirmWipe`) não aceitam token e não devolvem identidade. `pendingWipe` devolve só `String?` (o `orderId`). `confirmWipe` não devolve nada e se comporta igual com um par errado (não vira oráculo). +- Auditoria. No admin: a leitura é `read`/`admin_acs_devices`; o pedido é `write`/`admin_device`, com `wipe_ordered` ou `already_ordered` e `resourceId = orderId`. Pelo aparelho: `write`/`acs_device`, com `wipe_delivered` (só na 1ª entrega) e `wipe_confirmed`; o `userId` é o `targetUserId` do pedido (o aparelho age em nome da conta) e o `resourceId` é o `orderId`. +- INV-03: limpar o aparelho nunca descarta um alerta no servidor. Os alertas seguem `pending` lá, e o escalonamento da #44 continua valendo. O app não chama `acknowledge` nem `resolve` durante o wipe. +- A chave do SQLCipher nunca aparece em log nem em mensagem de erro (LGPD-RF09). +- Não editar `lib/src/generated/` à mão: rodar `serverpod generate`. A migração sai de `serverpod create-migration`. +- Copy em pt-BR. Mensagens dinâmicas com `Semantics(liveRegion: true)`. Alvos de toque de 48×52 também nos diálogos do admin. Botão destrutivo com `AdminColors.redOnSurface`. +- Só fixtures sintéticas (`seed_e2e_fixtures.dart`). Nenhum dado real. +- Commits sem atribuição de IA (regra do `CLAUDE.md` do repositório, que prevalece sobre o lembrete do harness). + +## Decisões + +- **D1 — Identidade do aparelho = o `deviceId` da instalação**, sem tabela de aparelhos nova: a lista vem de `acs_refresh_tokens` ∪ `acs_upload_tokens`, agrupada por `deviceId`, e `lastSeenAt` é o maior `issuedAt`. O wipe troca o `deviceId` (`DeviceIdStore.delete`), então o id antigo nunca volta, e por isso o índice único em `device_wipe_orders.deviceId` basta para a idempotência. +- **D2 — O wipe remoto ignora o bloqueio de visitas não enviadas.** O "Limpar este aparelho" do ACS continua recusando (`WipeBlocked`). O remoto apaga tudo, inclusive visitas pendentes, em conflito, recusadas, legadas e de outros donos, porque o caso é aparelho perdido ou roubado. O diálogo do admin avisa disso antes da confirmação. +- **D3 — Prazo de entrega.** Com o aparelho online, ele recebe o pedido em até 15 min: o JWT vivo (15 min) não é revogado por chamada, mas a renovação seguinte recebe `DeviceWipeRequiredException`. A partida, a volta do segundo plano e a volta da conexão perguntam `pendingWipe` na hora. Aparelho offline para sempre não é alcançável: limite registrado, porque é inerente ao modelo. +- **D4 — Sem cancelamento.** As sessões já foram revogadas na transação do pedido, e um "desfazer" deixaria um pedido entregue pela metade. Se o pedido foi um engano, o ACS entra de novo depois da limpeza. +- **D5 — Revogação por aparelho, não por conta.** O pedido revoga os tokens de **todos** os usuários daquele `deviceId` (a fila é por dono, e o aparelho pode ter vários donos), porque o aparelho inteiro é apagado. As outras sessões do ACS em outros aparelhos não são tocadas: cortar a conta inteira é o `setAcsActive(false)` da #43. +- **D6 — Rotação da chave por evento, não por calendário (CRIPTO-APP-CACHE).** A chave é trocada no wipe remoto e no "Limpar este aparelho" (`VisitDatabase.destroy`: fecha, apaga o arquivo e os sidecars, apaga a `.recuperado` e apaga a chave). Não há `PRAGMA rekey` periódico, porque ele reescreve o arquivo inteiro com visitas pendentes dentro, e uma falha no meio arrisca exatamente o que a fila protege. Registrar no `spec/lgpd_design.md` §5.1 e no `PROGRESS.md`. +- **D7 — Diário antes de apagar.** `WipeJournal` (em `shared_preferences`; guarda só `orderId` e o `deviceId` antigo, nada sensível) é gravado **antes** de qualquer exclusão e tem a fase `erasing` ou `erased`. Na partida, `erasing` refaz a limpeza (que é idempotente) e `erased` só reenvia a confirmação. O diário só é apagado depois que `confirmWipe` responder. +- **D8 — Certificado por aparelho adiado** (ver Global Constraints). Fica registrado que o certificado de dev `acs-area-12` compartilhado continua no APK de debug, e que o release já recusa a chave privada no asset. + +## Review Focus + +1. **Pedido chega com o painel aberto e a fila com visitas em RAM** (o ACS está no meio de um formulário quando o app volta do segundo plano). Esperado: o painel some, a fila em memória é descartada sem tentar gravar, o banco é apagado e a tela `device_wiped` aparece. Nenhum `setState` pode rodar depois do `dispose` do painel. → widget `wipe remoto com painel aberto desmonta o painel e mostra device_wiped` (Task 5). +2. **O processo morre no meio da limpeza** (diário em `erasing`, banco já apagado, chave ainda presente). Esperado: a partida seguinte termina a limpeza **antes** de abrir o banco e confirma. → teste `diário em erasing termina a limpeza na partida antes de abrir o banco` (Task 4). +3. **Três gatilhos ao mesmo tempo** (o retorno do segundo plano dispara `pendingWipe`, a renovação recebe `DeviceWipeRequiredException` e a conexão volta). Esperado: uma limpeza só e uma confirmação só. → teste `gatilhos concorrentes compartilham uma só limpeza` (Task 4). +4. **`confirmWipe` falha sem rede depois da limpeza.** Esperado: o diário fica em `erased`, o app segue usável com um `deviceId` novo, e a confirmação é reenviada na próxima partida ou volta da conexão. O login novo funciona, porque o `deviceId` novo não tem pedido. → teste `confirmação sem rede fica no diário e é reenviada` (Task 4). +5. **Coordenador pede limpeza de aparelho de ACS de outra UBS, ou de um `deviceId` que o ACS nunca usou.** Esperado: a mesma recusa `Aparelho não encontrado.`, uma linha `denied` e nenhum token revogado. → teste `coordenador fora da UBS e aparelho alheio recebem a mesma recusa sem revogar nada` (Task 1). + +--- + +### Task 1: Backend — pedido de limpeza e listagem de aparelhos (admin) + +**Files:** +- Create: `backend/sinalacs_server/lib/src/models/device_wipe_order.spy.yaml` (table `device_wipe_orders`) +- Create: `backend/sinalacs_server/lib/src/models/enums/device_wipe_reason.spy.yaml`, `.../enums/device_wipe_state.spy.yaml` +- Create: `backend/sinalacs_server/lib/src/models/api/admin_acs_device.spy.yaml`, `.../api/admin_device_wipe_result.spy.yaml` (siga a pasta `api/` dos modelos da #43) +- Create: `backend/sinalacs_server/lib/src/application/devices/device_wipe_service.dart` +- Create: `backend/sinalacs_server/lib/src/infrastructure/database/orm_device_wipe_store.dart` +- Modify: `backend/sinalacs_server/lib/src/runtime/alert_runtime.dart` (add `deviceWipeServiceFor`, next to `adminAccountServiceFor`, line 364) +- Modify: `backend/sinalacs_server/lib/src/endpoints/admin_endpoint.dart` (two methods after `resetStaffMfa`) +- Create: migration via `serverpod create-migration` +- Test: `backend/sinalacs_server/test/unit/device_wipe_service_test.dart`, `backend/sinalacs_server/test/integration/device_wipe_store_test.dart` + +**Interfaces:** +- Produces (models): + - `DeviceWipeOrder { UuidValue? id (defaultPersist=random), String deviceId, UuidValue targetUserId (relation users), UuidValue requestedBy (relation users), DeviceWipeReason reason, DateTime requestedAt, DateTime? deliveredAt, DateTime? confirmedAt }`, with a unique index `device_wipe_orders_device_key` on `deviceId`. + - `enum DeviceWipeReason { lost, stolen, replaced }` + - `enum DeviceWipeState { pending, delivered, confirmed }` + - `AdminAcsDevice { String deviceId, String label, DateTime lastSeenAt, DeviceWipeState? wipeState, DateTime? wipeRequestedAt, DateTime? wipeConfirmedAt }`, where `label = 'Aparelho ' + first 8 chars of deviceId`. + - `AdminDeviceWipeResult { String orderId, DateTime requestedAt, bool alreadyOrdered }` +- Produces (`DeviceWipeStore`, abstract, in `device_wipe_service.dart`): + - `Future> devicesOf(String acsId)` (ordered by `lastSeenAt` desc) + - `Future deviceBelongsTo(String acsId, String deviceId)` + - `Future<({DeviceWipeOrder order, bool created})> orderWipe({required String deviceId, required String targetUserId, required String requestedBy, required DeviceWipeReason reason, required DateTime at})`. It runs in **one transaction**: insert or read the existing order (unique index), then `revokedAt = at` on every live row of `acs_refresh_tokens` and `acs_upload_tokens` with that `deviceId`. When the order already exists it revokes nothing new. + - Task 2 uses: `Future orderFor(String deviceId)`, `Future markDelivered(String orderId, DateTime at)` (true only the first time), `Future markConfirmed(String deviceId, String orderId, DateTime at)` (true only the first time, and only if the pair matches). +- Produces (`DeviceWipeService`): + - `Future> devicesOf(AuthenticatedUser user, {required String acsId})` + - `Future orderWipe(AuthenticatedUser user, {required String acsId, required String deviceId, required DeviceWipeReason reason})` +- Produces (endpoint): `admin.acsDevices(accessToken, acsId) → List` and `admin.orderDeviceWipe(accessToken, acsId, deviceId, reason) → AdminDeviceWipeResult`. +- Consumes: `AdminScopeResolver.resolve(user, recurso: 'admin_acs', actionType: …)`, `AdminAccountStore.acsById(scope, acsId)` (the ACS must be in scope), and `AuditTrail.record(AuditEvent)` with the audit/deny pattern of `admin_account_service.dart:570-620`. + +- [ ] **Step 1: Write the failing unit tests** in `test/unit/device_wipe_service_test.dart`, with fakes for the store, the scope resolver and the audit (same doubles as `admin_account_service_test.dart`): + - `admin pede limpeza: created, revoga e audita wipe_ordered`. Expect `result.alreadyOrdered == false`, `result.orderId == fakeStore.lastOrder.id`, and an audit line `(write, admin_device, wipe_ordered, resourceId: orderId)`. + - `pedido repetido devolve alreadyOrdered com o requestedAt original e audita already_ordered`. + - `coordenador fora da UBS e aparelho alheio recebem a mesma recusa sem revogar nada`. Three cases: ACS of another UBS; a `deviceId` with no token for that ACS; `deviceId == InstitutionalAuthService.deviceIdAbsent`. Each one throws `AdminInvalidRequestException` with `message == 'Aparelho não encontrado.'`, writes a `denied` line, and leaves `fakeStore.orderWipeCalls == 0`. + - `paciente e ACS não alcançam` (the resolver refuses, as in #43). + - `listagem audita read admin_acs_devices antes de ler e esconde o sentinela`. +- [ ] **Step 2:** Run `cd backend/sinalacs_server && dart test test/unit/device_wipe_service_test.dart`. Expected: FAIL (`DeviceWipeService` is not defined). +- [ ] **Step 3:** Write the models and run `serverpod generate` and then `serverpod create-migration`. Implement `DeviceWipeService` and the `DeviceWipeStore` interface, register the service in `AlertRuntime`, and add the two endpoint methods with doc comments in the style of `setAcsActive`. In `OrmDeviceWipeStore.orderWipe`, use `session.db.transaction` + `INSERT … ON CONFLICT ("deviceId") DO NOTHING RETURNING` (no row returned = read the existing one), then the two `UPDATE … SET "revokedAt" = @at WHERE "deviceId" = @d AND "revokedAt" IS NULL` statements, only when `created`. +- [ ] **Step 4: Write the integration tests** in `test/integration/device_wipe_store_test.dart` (`withServerpod`, same setup as `admin_account_store_test.dart`): + - `orderWipe revoga refresh e envio de todos os donos do deviceId e só dele`: two users on device D and one user on device E. After the order, every live row of D has `revokedAt` set and E is untouched. + - `segundo orderWipe não cria linha nem revoga de novo` (`created == false`, same `id`). + - `devicesOf agrupa por deviceId, lastSeenAt = maior issuedAt, junta estado do pedido`: states `pending` → `delivered` → `confirmed` follow `deliveredAt`/`confirmedAt`. +- [ ] **Step 5:** Run `dart test test/unit/device_wipe_service_test.dart test/integration/device_wipe_store_test.dart` (test database up, as described in `backend/CLAUDE.md`). Expected: PASS. Then run `dart test` for the whole backend. Expected: no regression. +- [ ] **Step 6: Commit** `feat(backend): pedido de limpeza remota do aparelho do ACS pelo backoffice (#81)` + +### Task 2: Backend — entrega e confirmação pelo aparelho, e recusa no login e na renovação + +**Files:** +- Create: `backend/sinalacs_server/lib/src/models/exceptions/device_wipe_required_exception.spy.yaml` (`orderId: String`, `message: String`) +- Modify: `backend/sinalacs_server/lib/src/endpoints/devices_endpoint.dart` (two public methods) +- Modify: `backend/sinalacs_server/lib/src/application/devices/device_wipe_service.dart` (device-side methods) +- Modify: `backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart` (`loginInstitutional` line 78, `refreshSession` line 184) +- Test: `backend/sinalacs_server/test/unit/device_wipe_service_test.dart` (new group), `backend/sinalacs_server/test/integration/device_wipe_endpoint_test.dart` + +**Interfaces:** +- Consumes: `DeviceWipeStore.orderFor` / `markDelivered` / `markConfirmed` (Task 1). +- Produces: + - `DeviceWipeService.pendingOrderId(String deviceId) → Future`. It returns the `orderId` when an order exists and is not confirmed, and marks the delivery plus the `wipe_delivered` audit line only the first time. + - `DeviceWipeService.confirm(String deviceId, String orderId) → Future`. On the first valid confirmation it records `wipe_confirmed`. A wrong pair or a repeat is a silent no-op. + - `DeviceWipeService.ensureDeviceNotMarked(String? deviceId) → Future`. It throws `DeviceWipeRequiredException(orderId: …, message: 'Este aparelho precisa ser limpo a pedido da coordenação.')` whenever **any** order exists for that `deviceId`, confirmed or not: a cleaned device never reuses the old id (D1), so an old id that shows up again is treated as a device to clean. A null or empty `deviceId`, or the sentinel, means no check. + - Endpoints: `devices.pendingWipe(String deviceId) → String?` and `devices.confirmWipe(String deviceId, String orderId) → void`. Neither one requires a token. + +- [ ] **Step 1: Write the failing unit tests:** + - `pendingOrderId marca entrega e audita só na primeira vez`. + - `pendingOrderId de aparelho sem pedido, ou já confirmado, devolve null sem auditar`. + - `confirm com par errado não grava nem audita; com par certo grava uma vez`. + - `ensureDeviceNotMarked lança com o orderId; sentinela e vazio passam`. +- [ ] **Step 2: Write the failing integration test** `device_wipe_endpoint_test.dart`. ACS fixture with password and a refresh token on device D; order the wipe through `admin.orderDeviceWipe` with the admin session. Then: + - `refreshSession(refreshToken, deviceId: D)` throws `DeviceWipeRequiredException` with `orderId`. It must **not** be `SessionExpiredException`: the app has to tell "wipe" apart from "session expired". + - `loginInstitutional(…, deviceId: D)` with the correct password throws `DeviceWipeRequiredException`, and `loginInstitutional(…, deviceId: novo)` returns a session. + - `devices.pendingWipe(D) == orderId`; `devices.confirmWipe(D, orderId)`; then `devices.pendingWipe(D) == null`, and `loginInstitutional(…, deviceId: D)` throws again (the old id stays marked forever). + - `audit_logs` has `wipe_ordered`, `wipe_delivered` and `wipe_confirmed` with the same `resourceId`. +- [ ] **Step 3:** Run both. Expected: FAIL. +- [ ] **Step 4:** Implement. In `auth_endpoint.dart`, call `ensureDeviceNotMarked(deviceId)` **before** password verification in `loginInstitutional` and before `RefreshTokenService.refresh` in `refreshSession`. The order revoked the tokens already, so the check comes first so the answer is the wipe, not the generic refusal. Run `serverpod generate`. +- [ ] **Step 5:** Run `dart test`. Expected: PASS, with no regression in `refresh_token_test.dart`, `auth_*` and `upload_token_test.dart`. +- [ ] **Step 6: Commit** `feat(backend): aparelho recebe e confirma a limpeza remota; login e renovação recusam aparelho marcado (#81)` + +### Task 3: ACS — destruir banco e chave (rotação) e usar isso no "Limpar este aparelho" + +**Files:** +- Modify: `apps/acs/lib/core/database/sqlcipher_visit_store.dart` (`VisitDatabase`, plus the `VisitStorage` implementation at line 237) +- Modify: `apps/acs/lib/core/services/offline_visit_queue.dart` (`VisitStorage` interface, line 270) +- Modify: `apps/acs/lib/app/app.dart` (`wipe` in `_DeviceCare`, lines 287-321) +- Test: `apps/acs/test/device_wipe_test.dart` (extend), `apps/acs/integration_test/encrypted_storage_test.dart` (new case) + +**Interfaces:** +- Produces: + - `VisitDatabase.destroy() → Future`. It closes the connection, waits for an `open()` in flight, calls `EncryptedLocalDatabase.deleteDatabaseFile`, `deleteRecoveryCopy` and `_keyStore.delete()`. The next `open()` creates a new key and an empty database. + - `VisitStorage.destroyAll() → Future`, which delegates to `destroy()`. It is unconditional: it does **not** check for visits, because that is the caller's job. +- Behavior change: `_DeviceCare.wipe` calls `_storage.destroyAll()` right after a successful `_storage.wipeAllData()`. The local wipe keeps refusing first (`WipeBlocked`) and only then rotates. + +- [ ] **Step 1: Write the failing tests** in `test/device_wipe_test.dart`. Use `InMemoryDatabaseKeyStore(initialKey: 'a'*64)` and the FFI path with `allowUnencryptedForTesting: true`: + - `destroy apaga o arquivo, a cópia recuperada e troca a chave`. Afterwards, `File(path).existsSync() == false`, `.recuperado` is gone, and `await keyStore.readOrCreate() != 'a'*64`. + - `open depois de destroy devolve banco vazio no schema atual`. + - `Limpar este aparelho sem visitas troca a chave`. + - `Limpar este aparelho com visita recusa e mantém arquivo e chave` (regression of `WipeBlocked`). +- [ ] **Step 2:** Run `cd apps/acs && flutter test test/device_wipe_test.dart`. Expected: FAIL. +- [ ] **Step 3:** Implement. +- [ ] **Step 4:** Add the case `destroy troca a chave no aparelho` to `integration_test/encrypted_storage_test.dart`. On the device: write a visit, `destroy()`, open again with the new key, and check that the raw file does not contain the old visit and that the old key no longer opens the new file. +- [ ] **Step 5:** Run `flutter test test/device_wipe_test.dart` and `flutter analyze`. Expected: PASS. Run `flutter test integration_test/encrypted_storage_test.dart -d emulator-5554` (stack up per the `validacao-e2e` skill). Expected: PASS. +- [ ] **Step 6: Commit** `feat(acs): limpar o aparelho apaga o banco e troca a chave do SQLCipher (#81, CRIPTO-APP-CACHE)` + +### Task 4: ACS — `RemoteWipeCoordinator`, diário e cliente + +**Files:** +- Create: `apps/acs/lib/core/services/remote_wipe.dart` (`RemoteWipeCoordinator`, `WipeJournal`, `SharedPreferencesWipeJournal`, `MemoryWipeJournal`, `DeviceEraser`) +- Modify: `apps/acs/lib/core/security/session_token_store.dart` (`DeviceIdStore.delete()`; the memory implementation too) and `secure_session_token_store.dart` (`SecureStorageDeviceIdStore.delete()` deletes `acs_device_id` and resets `_pending`) +- Modify: `apps/acs/lib/core/network/backend_client.dart` (`AcsBackend`, `BackendClient`, `MisconfiguredBackend`, and the test fakes that implement `AcsBackend`) +- Test: `apps/acs/test/remote_wipe_test.dart`, `apps/acs/test/backend_client_wipe_test.dart` + +**Interfaces:** +- Produces (`AcsBackend`): + - `Future pendingDeviceWipe(String deviceId)` → `devices.pendingWipe`. A network failure becomes `BackendFailure` (recoverable). + - `Future confirmDeviceWipe(String deviceId, String orderId)` → `devices.confirmWipe`. + - `Future forgetLocalSession()`. It bumps the generation, drops the in-memory session and calls `tokenStore.clear()`, inside `_exclusive` and **without network**. + - `set onDeviceWipeRequired(void Function(String orderId)? callback)`. `login()` and `renewSession()` map `DeviceWipeRequiredException` to this callback **and** throw `BackendFailure('Este aparelho precisa ser limpo a pedido da coordenação.', isRecoverable: false)`. They never call `onSessionExpired`. +- Produces (`WipeJournal`): `Future<({String orderId, String deviceId, WipePhase phase})?> read()`, `Future write(String orderId, String deviceId, WipePhase phase)`, `Future clear()`, and `enum WipePhase { erasing, erased }`. +- Produces (`DeviceEraser`): typedef `Future Function()`. The app builds it from `VisitStorage.destroyAll` + `UploadTokenStore.clearAll` + `AcsBackend.forgetLocalSession` + `DeviceIdStore.delete` (Task 5). +- Produces (`RemoteWipeCoordinator({required AcsBackend backend, required DeviceIdStore deviceIds, required WipeJournal journal, required DeviceEraser erase, void Function()? onWiped})`): + - `Future resumePending()`. Call it at startup, **before** any database open. Journal in `erasing`: erase, then `erased`, then confirm. Journal in `erased`: only confirm. + - `Future check()`. It asks `pendingDeviceWipe` and applies the wipe when there is an order. A network failure returns `false` and never throws. + - `Future apply(String orderId)`. Single-flight: concurrent calls share the same `Future`. Order of steps: `journal.write(erasing)` → `erase()` → `journal.write(erased)` → `onWiped()` → `confirmDeviceWipe(oldDeviceId, orderId)` → `journal.clear()`. A confirmation failure keeps `erased`. + +- [ ] **Step 1: Write the failing tests** in `test/remote_wipe_test.dart`, with a fake `AcsBackend`, a counting eraser, `MemoryWipeJournal` and `MemoryDeviceIdStore`: + - `check sem pedido não apaga nada`. + - `check com pedido apaga, chama onWiped e confirma com o deviceId antigo`. The confirmation must carry the id read **before** `erase`. + - `gatilhos concorrentes compartilham uma só limpeza`: `check()` + `apply(id)` + `apply(id)` at the same time give `eraseCalls == 1` and `confirmCalls == 1`. + - `confirmação sem rede fica no diário e é reenviada`: after a failure the phase is `erased`, and `resumePending()` confirms and clears the journal. + - `diário em erasing termina a limpeza na partida antes de abrir o banco`: `resumePending()` calls `erase` again, then confirms. + - `falha do erase mantém erasing e não confirma` (the next startup tries again). +- [ ] **Step 2: Write the failing tests** in `test/backend_client_wipe_test.dart`, with a stubbed generated client in the same style as the existing `BackendClient` tests: + - `renewSession com DeviceWipeRequiredException chama onDeviceWipeRequired e não onSessionExpired`. + - `login com DeviceWipeRequiredException chama onDeviceWipeRequired e lança BackendFailure não recuperável`. + - `forgetLocalSession apaga o token e a sessão sem rede`. +- [ ] **Step 3:** Run `flutter test test/remote_wipe_test.dart test/backend_client_wipe_test.dart`. Expected: FAIL. +- [ ] **Step 4:** Implement. `SharedPreferencesWipeJournal` uses the key `acs_remote_wipe_journal`, stored as JSON `{orderId, deviceId, phase}`. +- [ ] **Step 5:** Run `flutter test` (all of it) and `flutter analyze`. Expected: PASS. `test/session_store_wiring_test.dart` must stay green: `remote_wipe.dart` must not bring `dart:ui` into the import path of `backend_client.dart`, so keep `shared_preferences` out of that path (`SharedPreferencesWipeJournal` lives in `remote_wipe.dart`, which `backend_client.dart` does not import). +- [ ] **Step 6: Commit** `feat(acs): coordenador da limpeza remota com diário e confirmação (#81)` + +### Task 5: ACS — gatilhos, desmontagem do painel e tela `device_wiped` + +**Files:** +- Modify: `apps/acs/lib/app/app.dart` (`SinalAcsApp`: new optional parameter `WipeJournal? wipeJournal`; state: build the `RemoteWipeCoordinator`, call `resumePending()` before the first `VisitStorage` use, and `check()` on startup, on `AppLifecycleState.resumed` and on `connectivityChanges` → `true`; register `backend.onDeviceWipeRequired`) +- Create: `apps/acs/lib/app/device_wiped_screen.dart` +- Modify: `apps/acs/lib/main.dart` (pass `SharedPreferencesWipeJournal()`) +- Test: `apps/acs/test/remote_wipe_ui_test.dart` + +**Interfaces:** +- Consumes: `RemoteWipeCoordinator` (Task 4) and `VisitStorage.destroyAll` (Task 3). +- Produces: the `DeviceEraser` built in the state, in this order: discard `_queues`/`_scopes` **without saving** → `backend.forgetLocalSession()` → `_storage.destroyAll()` → `uploadTokens.clearAll(alsoOwners: owners)` (no `revokeUploadToken`: the server already revoked them) → `deviceIds.delete()`. `onWiped` runs `navigatorKey.currentState.pushAndRemoveUntil(DeviceWipedScreen route, (_) => false)`. +- `DeviceWipedScreen`: + - `Key('device_wiped')`, `Semantics(liveRegion: true)`. + - Exact copy: title **"Aparelho limpo pela coordenação"**; body **"Os dados do SinalACS deste aparelho foram apagados a pedido da coordenação da UBS, inclusive as visitas que ainda não tinham sido enviadas. Os alertas continuam com a equipe da UBS."** + - Button `Key('device_wiped_login')` **"Entrar de novo"**, which returns to a fresh `LoginScreen`. + - With the journal still in `erased` (confirmation pending), show the line `Key('device_wiped_pending_confirm')` **"A confirmação ainda não chegou ao servidor; será enviada quando houver conexão."** + - The `AppLockGate` does not apply to this screen. + +- [ ] **Step 1: Write the failing widget tests** in `test/remote_wipe_ui_test.dart`, with a fake backend that answers `pendingDeviceWipe` with `'ordem-1'` when the test asks for it: + - `pedido na partida apaga antes do login e mostra device_wiped`: the `LoginScreen` never calls `resumeSession`, and `eraseCalls == 1`. + - `wipe remoto com painel aberto desmonta o painel e mostra device_wiped`: log in, open the visit screen with a visit typed in, then `tester.binding.handleAppLifecycleStateChanged(paused → resumed)`. Expect `find.byKey(Key('device_wiped'))`, the panel gone, no exception (`tester.takeException() == null`), and the fake feed closed. + - `renovação com pedido leva à mesma tela, sem tela de reautenticação`. + - `Entrar de novo abre login com deviceId novo`: the next `login` receives a `deviceId` different from the old one. + - `conexão que volta pergunta pendingWipe`. + - `fonte 200% em 320x640 sem estouro na device_wiped` (`test/support/layout_harness.dart`). +- [ ] **Step 2:** Run `flutter test test/remote_wipe_ui_test.dart`. Expected: FAIL. +- [ ] **Step 3:** Implement. +- [ ] **Step 4:** Run `flutter test`, `flutter analyze` and `flutter test test/text_scale_test.dart`. Expected: PASS. +- [ ] **Step 5: Commit** `feat(acs): app aplica a limpeza remota na partida, na retomada, na volta da rede e na renovação (#81)` + +### Task 6: Admin — aparelhos do ACS e pedido de limpeza + +**Files:** +- Modify: `apps/admin/lib/core/data/admin_data_source.dart` (models + 2 methods), `backend_admin_data_source.dart`, `mock_admin_data_source.dart` (or wherever the mock lives) +- Create: `apps/admin/lib/app/acs_devices_dialog.dart` +- Modify: `apps/admin/lib/app/admin_accounts.dart` (button `Key('acs_devices_')` labelled **"Aparelhos"** on the ACS card) +- Test: `apps/admin/test/acs_devices_dialog_test.dart`, `apps/admin/test/admin_accounts_data_source_test.dart` (extend), `apps/admin/test/touch_targets_test.dart` (extend) + +**Interfaces:** +- Consumes: `admin.acsDevices` and `admin.orderDeviceWipe` (Task 1). +- Produces: + - `AdminDataSource.fetchAcsDevices(String acsId) → Future>` + - `AdminDataSource.orderDeviceWipe({required String acsId, required String deviceId, required DeviceWipeReason reason}) → Future` + - App models that mirror the server and translate **by name**: `AcsDevice { deviceId, label, lastSeenAt, wipeState?, wipeRequestedAt?, wipeConfirmedAt? }`, `DeviceWipeState`, `DeviceWipeReason`, `DeviceWipeOrderResult { orderId, requestedAt, alreadyOrdered }`. +- Copy: + - One row per device: `label` + "Último acesso em DD/MM às HH:MM". + - State: "Limpeza pedida às HH:MM" / "Pedido entregue ao aparelho" / "Aparelho limpo às HH:MM". A row with no order shows no state line. + - Button `Key('wipe_device_')` **"Limpar remotamente"**, hidden when an order already exists. + - Reason: `Key('wipe_reason')`, with "Perdido", "Roubado" and "Substituído". + - `confirmarDestrutiva` text: **"Limpar este aparelho remotamente? Ele perde o acesso agora e apaga os dados do SinalACS na próxima vez que se conectar, inclusive as visitas que ainda não foram enviadas. Isso não pode ser desfeito."**, with the confirm button **"Limpar aparelho"**. + - After the result, `alreadyOrdered` shows **"Este aparelho já tinha um pedido de limpeza."** The list is reloaded from the server. + - With no device: **"Nenhum aparelho com acesso registrado para este ACS."** + +- [ ] **Step 1: Write the failing tests:** + - `lista aparelhos com estado e último acesso`. + - `cancelar a confirmação não chama orderDeviceWipe`. + - `confirmar envia o motivo escolhido e recarrega a lista`. + - `alreadyOrdered mostra o aviso`. + - `recusa do servidor mostra texto fixo` (`AdminDataFailure`). + - `alvos de toque 48x52 no diálogo` (`touch_targets_test.dart`). + - `fonte 200% sem estouro`. + - Data source: `orderDeviceWipe traduz o motivo por nome`. +- [ ] **Step 2:** Run `cd apps/admin && flutter test test/acs_devices_dialog_test.dart`. Expected: FAIL. +- [ ] **Step 3:** Implement. +- [ ] **Step 4:** Run `flutter test` and `flutter analyze`. Expected: PASS, including `contrast_tokens_test.dart`. +- [ ] **Step 5: Commit** `feat(admin): aparelhos do ACS e limpeza remota pelo backoffice (#81)` + +### Task 7: Prova no emulador — aparelho limpo com visita não enviada + +**Files:** +- Create: `apps/acs/integration_test/remote_wipe_e2e.dart` (`_e2e` suffix so discovery skips it) +- Create: `scripts/qa/acs_remote_wipe_e2e.sh` (same anatomy as `scripts/qa/acs_alert_fallback_e2e.sh`: `lib_rele.sh`, `e2e_stack.sh up`, relay with `E2E_FIXTURES_FILE`, `adb reverse`, cleanup) +- Create: `scripts/qa/acs_remote_wipe_e2e_test.sh` (structure check with no Docker and no device, like `acs_alert_fallback_e2e_test.sh`) +- Test: the two files above + +**Interfaces:** +- Consumes: the relay's `/acs` and `/coordenador` routes (`otp_relay.py:76`), `integration_test/support/e2e_acs.dart` and `support/totp.dart`, and the coordinator's MFA activation flow by activation code, the same as `apps/admin/integration_test/admin_acs_gestao_e2e.dart`. + +- [ ] **Step 1: Write the integration test.** It runs the real `SinalAcsApp` with the Keystore stores, as in `full_journey_e2e.dart`: + 1. The ACS logs in through the screen. + 2. It records a visit with the offline decorator, so the visit stays pending. It keeps the `deviceId` from `SecureStorageDeviceIdStore`, then taps "Sair e encerrar o turno" (the visit stays on the device). + 3. With its own generated `Client`, the test logs in the coordinator (relay `/coordenador`, MFA activation + TOTP) and calls `admin.orderDeviceWipe(acsId, deviceId, DeviceWipeReason.stolen)`. It prints `E2E_WIPE_PEDIDO order= device=`. + 4. `handleAppLifecycleStateChanged(paused → resumed)`, then wait ≤ 30 s for `Key('device_wiped')`. + 5. Check on the device: the database file does not exist **or** reopens empty with a different key (it reads the key from `SecureStorageDatabaseKeyStore` before and after); `SecureStorageUploadTokenStore.owners()` is empty; the new `deviceId` ≠ the old one. + 6. Tap "Entrar de novo", log in again with the same credential and see the panel. This proves the new id works. Print `E2E_WIPE_OK`. +- [ ] **Step 2: Write the script.** After `E2E_WIPE_OK` it checks with `e2e_stack.sh psql`: + - `device_wipe_orders` for the device has `deliveredAt` and `confirmedAt` not null and `reason = stolen`; + - zero live rows in `acs_refresh_tokens`/`acs_upload_tokens` with the old `deviceId`; + - `audit_logs` has `wipe_ordered`, `wipe_delivered` and `wipe_confirmed` with the same `resourceId`, and the order's `userId` = coordinator; + - the visit created in step 2 is **not** in `visits` (it was never sent); + - `audit_chain_check` is green. + Save the log to `docs/superpowers/plans/2026-10-17-issue-81-e2e.log`. +- [ ] **Step 3:** Run `bash scripts/qa/acs_remote_wipe_e2e_test.sh`. Expected: PASS. +- [ ] **Step 4:** Run `./scripts/qa/acs_remote_wipe_e2e.sh` against `emulator-5554`. Expected: final line `E2E de limpeza remota: OK` and every SQL check green. If it fails, stop and debug (`superpowers:systematic-debugging`); do not loosen any check. +- [ ] **Step 5: Regression:** run `./scripts/qa/acs_full_e2e.sh` and `./scripts/qa/admin_acs_gestao_e2e.sh`. Expected: both green. +- [ ] **Step 6: Commit** `test(acs): e2e no emulador da limpeza remota com visita não enviada (#81)` + +### Task 8: Documentação, auditoria do PRD e issue de continuação + +**Files:** +- Modify: `PROGRESS.md` (new section "Limpeza remota do aparelho e rotação da chave (#81)": D1–D8, proofs, what stays open) +- Modify: `CLAUDE.md` (root: in "Already delivered" add remote wipe + event-based rotation, and remove "a supervisor-forced device wipe (no supervisor login exists)" from "Still missing"; add `devices.pendingWipe`/`confirmWipe` and `admin.acsDevices`/`orderDeviceWipe` to the endpoint list), `apps/CLAUDE.md` (a paragraph "ACS: limpeza remota (#81)"), `backend/CLAUDE.md` (the new table and the auth check) +- Modify: `spec/lgpd_design.md` §5.1 (D6) and `spec/lgpd_data_audit.md` (table `device_wipe_orders`: `deviceId` is a pseudonymous installation id; retained as audit evidence; re-count the tables in the latest `definition.sql`) +- Modify: `docs/auditoria-prd/2026-10-15-lacunas-prd.md`: R3 → **entregue** (except the certificate), CRIPTO-APP-CACHE → **entregue** (event rotation, D6), STRIDE-S stays **parcial**, item 7 of the backlog crossed out with a reference to the new issue +- Modify: `docs/telas-admin.md` and `docs/telas-acs.md` (describe the screens; screenshots named as pending if not captured — never invented) + +- [ ] **Step 1:** Write the docs above. In "Aberto", list: offline device never reached (D3); live JWT up to 15 min (D3); per-device certificate (D8); API 24–29 and a physical device unproven. +- [ ] **Step 2:** Create the follow-up issue. **Confirm the text with the owner before running it:** `gh issue create --title "[P1] Certificado de cliente MQTT por aparelho (STRIDE-S)" --body-file `. The body must cover: a CSR generated on the device, signed by a device CA in the backend, revocation by CRL in Mosquitto with a reload check (spike), the dependency choice (pointycastle/basic_utils, which needs an entry in `spec/stack.md`), and production PKI as infra. Put the issue number in the PRD audit and in `PROGRESS.md`. +- [ ] **Step 3:** Run `graphify update .`, `cd backend/sinalacs_server && dart test`, `cd apps/acs && flutter test`, `cd apps/admin && flutter test`, and `./scripts/qa/ci_invariants.sh`. Expected: all green. +- [ ] **Step 4: Commit** `docs: registra a limpeza remota e a rotação da chave do cache (#81)` From 4748ce5c98d9371b6349fb1fd6ce09cc26a10193 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 13:21:23 -0400 Subject: [PATCH 02/17] feat(backend): pedido de limpeza remota do aparelho do ACS pelo backoffice (#81) --- .../src/protocol/api/admin_acs_device.dart | 152 + .../api/admin_device_wipe_result.dart | 101 + .../lib/src/protocol/client.dart | 253 +- .../lib/src/protocol/device_wipe_order.dart | 181 + .../protocol/enums/device_wipe_reason.dart | 44 + .../src/protocol/enums/device_wipe_state.dart | 44 + .../lib/src/protocol/protocol.dart | 1532 +++--- .../devices/device_wipe_service.dart | 168 + .../lib/src/endpoints/admin_endpoint.dart | 31 + .../src/generated/api/admin_acs_device.dart | 166 + .../api/admin_device_wipe_result.dart | 112 + .../lib/src/generated/device_wipe_order.dart | 660 +++ .../lib/src/generated/endpoints.dart | 83 +- .../generated/enums/device_wipe_reason.dart | 44 + .../generated/enums/device_wipe_state.dart | 44 + .../lib/src/generated/protocol.dart | 1713 +++--- .../lib/src/generated/protocol.yaml | 2 + .../database/orm_device_wipe_store.dart | 164 + .../src/models/api/admin_acs_device.spy.yaml | 13 + .../api/admin_device_wipe_result.spy.yaml | 7 + .../lib/src/models/device_wipe_order.spy.yaml | 24 + .../models/enums/device_wipe_reason.spy.yaml | 9 + .../models/enums/device_wipe_state.spy.yaml | 9 + .../lib/src/runtime/alert_runtime.dart | 11 + .../20261017171640323/definition.json | 4656 +++++++++++++++++ .../20261017171640323/definition.sql | 972 ++++ .../20261017171640323/definition_project.json | 3320 ++++++++++++ .../20261017171640323/migration.json | 138 + .../20261017171640323/migration.sql | 54 + .../migrations/migration_registry.txt | 1 + .../integration/device_wipe_store_test.dart | 187 + .../test_tools/serverpod_test_tools.dart | 288 +- .../test/unit/device_wipe_service_test.dart | 285 + 33 files changed, 13760 insertions(+), 1708 deletions(-) create mode 100644 backend/sinalacs_client/lib/src/protocol/api/admin_acs_device.dart create mode 100644 backend/sinalacs_client/lib/src/protocol/api/admin_device_wipe_result.dart create mode 100644 backend/sinalacs_client/lib/src/protocol/device_wipe_order.dart create mode 100644 backend/sinalacs_client/lib/src/protocol/enums/device_wipe_reason.dart create mode 100644 backend/sinalacs_client/lib/src/protocol/enums/device_wipe_state.dart create mode 100644 backend/sinalacs_server/lib/src/application/devices/device_wipe_service.dart create mode 100644 backend/sinalacs_server/lib/src/generated/api/admin_acs_device.dart create mode 100644 backend/sinalacs_server/lib/src/generated/api/admin_device_wipe_result.dart create mode 100644 backend/sinalacs_server/lib/src/generated/device_wipe_order.dart create mode 100644 backend/sinalacs_server/lib/src/generated/enums/device_wipe_reason.dart create mode 100644 backend/sinalacs_server/lib/src/generated/enums/device_wipe_state.dart create mode 100644 backend/sinalacs_server/lib/src/infrastructure/database/orm_device_wipe_store.dart create mode 100644 backend/sinalacs_server/lib/src/models/api/admin_acs_device.spy.yaml create mode 100644 backend/sinalacs_server/lib/src/models/api/admin_device_wipe_result.spy.yaml create mode 100644 backend/sinalacs_server/lib/src/models/device_wipe_order.spy.yaml create mode 100644 backend/sinalacs_server/lib/src/models/enums/device_wipe_reason.spy.yaml create mode 100644 backend/sinalacs_server/lib/src/models/enums/device_wipe_state.spy.yaml create mode 100644 backend/sinalacs_server/migrations/20261017171640323/definition.json create mode 100644 backend/sinalacs_server/migrations/20261017171640323/definition.sql create mode 100644 backend/sinalacs_server/migrations/20261017171640323/definition_project.json create mode 100644 backend/sinalacs_server/migrations/20261017171640323/migration.json create mode 100644 backend/sinalacs_server/migrations/20261017171640323/migration.sql create mode 100644 backend/sinalacs_server/test/integration/device_wipe_store_test.dart create mode 100644 backend/sinalacs_server/test/unit/device_wipe_service_test.dart diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_acs_device.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_acs_device.dart new file mode 100644 index 0000000..9665155 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_acs_device.dart @@ -0,0 +1,152 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod_client/serverpod_client.dart' as _i1; +import '../enums/device_wipe_state.dart' as _i2; + +/// Aparelho de um ACS na listagem do backoffice (#81). O rótulo é derivado do +/// `deviceId` (`Aparelho ` + 8 primeiros caracteres): o app não envia modelo +/// nem nome do aparelho, e o id completo não precisa chegar à tela. +abstract class AdminAcsDevice implements _i1.SerializableModel { + AdminAcsDevice._({ + required this.deviceId, + required this.label, + required this.lastSeenAt, + this.wipeState, + this.wipeRequestedAt, + this.wipeConfirmedAt, + }); + + factory AdminAcsDevice({ + required String deviceId, + required String label, + required DateTime lastSeenAt, + _i2.DeviceWipeState? wipeState, + DateTime? wipeRequestedAt, + DateTime? wipeConfirmedAt, + }) = _AdminAcsDeviceImpl; + + factory AdminAcsDevice.fromJson(Map jsonSerialization) { + return AdminAcsDevice( + deviceId: jsonSerialization['deviceId'] as String, + label: jsonSerialization['label'] as String, + lastSeenAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['lastSeenAt'], + ), + wipeState: jsonSerialization['wipeState'] == null + ? null + : _i2.DeviceWipeState.fromJson( + (jsonSerialization['wipeState'] as String), + ), + wipeRequestedAt: jsonSerialization['wipeRequestedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['wipeRequestedAt'], + ), + wipeConfirmedAt: jsonSerialization['wipeConfirmedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['wipeConfirmedAt'], + ), + ); + } + + String deviceId; + + String label; + + /// Maior `issuedAt` entre os tokens do aparelho. + DateTime lastSeenAt; + + /// `null` = sem pedido de limpeza. + _i2.DeviceWipeState? wipeState; + + DateTime? wipeRequestedAt; + + DateTime? wipeConfirmedAt; + + /// Returns a shallow copy of this [AdminAcsDevice] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAcsDevice copyWith({ + String? deviceId, + String? label, + DateTime? lastSeenAt, + _i2.DeviceWipeState? wipeState, + DateTime? wipeRequestedAt, + DateTime? wipeConfirmedAt, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAcsDevice', + 'deviceId': deviceId, + 'label': label, + 'lastSeenAt': lastSeenAt.toJson(), + if (wipeState != null) 'wipeState': wipeState?.toJson(), + if (wipeRequestedAt != null) 'wipeRequestedAt': wipeRequestedAt?.toJson(), + if (wipeConfirmedAt != null) 'wipeConfirmedAt': wipeConfirmedAt?.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAcsDeviceImpl extends AdminAcsDevice { + _AdminAcsDeviceImpl({ + required String deviceId, + required String label, + required DateTime lastSeenAt, + _i2.DeviceWipeState? wipeState, + DateTime? wipeRequestedAt, + DateTime? wipeConfirmedAt, + }) : super._( + deviceId: deviceId, + label: label, + lastSeenAt: lastSeenAt, + wipeState: wipeState, + wipeRequestedAt: wipeRequestedAt, + wipeConfirmedAt: wipeConfirmedAt, + ); + + /// Returns a shallow copy of this [AdminAcsDevice] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAcsDevice copyWith({ + String? deviceId, + String? label, + DateTime? lastSeenAt, + Object? wipeState = _Undefined, + Object? wipeRequestedAt = _Undefined, + Object? wipeConfirmedAt = _Undefined, + }) { + return AdminAcsDevice( + deviceId: deviceId ?? this.deviceId, + label: label ?? this.label, + lastSeenAt: lastSeenAt ?? this.lastSeenAt, + wipeState: wipeState is _i2.DeviceWipeState? ? wipeState : this.wipeState, + wipeRequestedAt: wipeRequestedAt is DateTime? + ? wipeRequestedAt + : this.wipeRequestedAt, + wipeConfirmedAt: wipeConfirmedAt is DateTime? + ? wipeConfirmedAt + : this.wipeConfirmedAt, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_device_wipe_result.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_device_wipe_result.dart new file mode 100644 index 0000000..8a35183 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_device_wipe_result.dart @@ -0,0 +1,101 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod_client/serverpod_client.dart' as _i1; + +/// Resposta do pedido de limpeza remota (#81). Repetir o pedido é idempotente: +/// `alreadyOrdered` e o `requestedAt` original. +abstract class AdminDeviceWipeResult implements _i1.SerializableModel { + AdminDeviceWipeResult._({ + required this.orderId, + required this.requestedAt, + required this.alreadyOrdered, + }); + + factory AdminDeviceWipeResult({ + required String orderId, + required DateTime requestedAt, + required bool alreadyOrdered, + }) = _AdminDeviceWipeResultImpl; + + factory AdminDeviceWipeResult.fromJson( + Map jsonSerialization, + ) { + return AdminDeviceWipeResult( + orderId: jsonSerialization['orderId'] as String, + requestedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['requestedAt'], + ), + alreadyOrdered: _i1.BoolJsonExtension.fromJson( + jsonSerialization['alreadyOrdered'], + ), + ); + } + + String orderId; + + DateTime requestedAt; + + bool alreadyOrdered; + + /// Returns a shallow copy of this [AdminDeviceWipeResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminDeviceWipeResult copyWith({ + String? orderId, + DateTime? requestedAt, + bool? alreadyOrdered, + }); + @override + Map toJson() { + return { + '__className__': 'AdminDeviceWipeResult', + 'orderId': orderId, + 'requestedAt': requestedAt.toJson(), + 'alreadyOrdered': alreadyOrdered, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminDeviceWipeResultImpl extends AdminDeviceWipeResult { + _AdminDeviceWipeResultImpl({ + required String orderId, + required DateTime requestedAt, + required bool alreadyOrdered, + }) : super._( + orderId: orderId, + requestedAt: requestedAt, + alreadyOrdered: alreadyOrdered, + ); + + /// Returns a shallow copy of this [AdminDeviceWipeResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminDeviceWipeResult copyWith({ + String? orderId, + DateTime? requestedAt, + bool? alreadyOrdered, + }) { + return AdminDeviceWipeResult( + orderId: orderId ?? this.orderId, + requestedAt: requestedAt ?? this.requestedAt, + alreadyOrdered: alreadyOrdered ?? this.alreadyOrdered, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index ee75f90..ebb2b0a 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -40,50 +40,55 @@ import 'package:sinalacs_client/src/protocol/api/admin_password_reset_result.dar as _i18; import 'package:sinalacs_client/src/protocol/api/admin_staff_mfa_reset_result.dart' as _i19; +import 'package:sinalacs_client/src/protocol/api/admin_acs_device.dart' as _i20; +import 'package:sinalacs_client/src/protocol/api/admin_device_wipe_result.dart' + as _i21; +import 'package:sinalacs_client/src/protocol/enums/device_wipe_reason.dart' + as _i22; import 'package:sinalacs_client/src/protocol/api/admin_escalation_result.dart' - as _i20; + as _i23; import 'package:sinalacs_client/src/protocol/enums/escalation_reason.dart' - as _i21; + as _i24; import 'package:sinalacs_client/src/protocol/api/notice_history_page.dart' - as _i22; + as _i25; import 'package:sinalacs_client/src/protocol/api/terms_version_entry.dart' - as _i23; -import 'package:sinalacs_client/src/protocol/api/red_alert_result.dart' as _i24; -import 'package:sinalacs_client/src/protocol/api/alert_ack_result.dart' as _i25; -import 'package:sinalacs_client/src/protocol/api/alert_resolve_result.dart' as _i26; -import 'package:sinalacs_client/src/protocol/api/acs_open_alert.dart' as _i27; +import 'package:sinalacs_client/src/protocol/api/red_alert_result.dart' as _i27; +import 'package:sinalacs_client/src/protocol/api/alert_ack_result.dart' as _i28; +import 'package:sinalacs_client/src/protocol/api/alert_resolve_result.dart' + as _i29; +import 'package:sinalacs_client/src/protocol/api/acs_open_alert.dart' as _i30; import 'package:sinalacs_client/src/protocol/api/alert_status_result.dart' - as _i28; + as _i31; import 'package:sinalacs_client/src/protocol/api/development_login_result.dart' - as _i29; + as _i32; import 'package:sinalacs_client/src/protocol/api/totp_enrollment_start.dart' - as _i30; -import 'package:sinalacs_client/src/protocol/api/service_health.dart' as _i31; + as _i33; +import 'package:sinalacs_client/src/protocol/api/service_health.dart' as _i34; import 'package:sinalacs_client/src/protocol/api/notice_send_result.dart' - as _i32; + as _i35; import 'package:sinalacs_client/src/protocol/api/enrollment_token_result.dart' - as _i33; + as _i36; import 'package:sinalacs_client/src/protocol/api/enrollment_result.dart' - as _i34; + as _i37; import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' - as _i35; + as _i38; import 'package:sinalacs_client/src/protocol/api/patient_data_overview.dart' - as _i36; + as _i39; import 'package:sinalacs_client/src/protocol/api/patient_consent_record.dart' - as _i37; + as _i40; import 'package:sinalacs_client/src/protocol/enums/consent_purpose.dart' - as _i38; + as _i41; import 'package:sinalacs_client/src/protocol/api/terms_change_notice.dart' - as _i39; + as _i42; import 'package:sinalacs_client/src/protocol/api/patient_data_subject_request_record.dart' - as _i40; -import 'package:sinalacs_client/src/protocol/api/triage_result.dart' as _i41; -import 'package:sinalacs_client/src/protocol/api/ubs_contact.dart' as _i42; -import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' as _i43; -import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i44; -import 'protocol.dart' as _i45; +import 'package:sinalacs_client/src/protocol/api/triage_result.dart' as _i44; +import 'package:sinalacs_client/src/protocol/api/ubs_contact.dart' as _i45; +import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' + as _i46; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i47; +import 'protocol.dart' as _i48; /// Backoffice, só para `coordinator` e `admin`: leitura (issue #40), gestão de /// contas (issue #43), atendimento de pedidos do titular (issue #42) e @@ -420,16 +425,52 @@ class EndpointAdmin extends EndpointAuthenticated { }, ); + /// Aparelhos do ACS [acsId] (#81): rótulo, último uso e estado do pedido de + /// limpeza. Só `coordinator`/`admin`; o coordenador só na própria UBS. ACS + /// fora do escopo e inexistente recebem a mesma recusa; a leitura é auditada. + _i2.Future> acsDevices({ + required String accessToken, + required String acsId, + }) => caller.callServerEndpoint>( + 'admin', + 'acsDevices', + { + 'accessToken': accessToken, + 'acsId': acsId, + }, + ); + + /// Pede a limpeza remota do aparelho [deviceId] do ACS [acsId] (#81) e revoga + /// na hora os tokens dele, numa transação só. O aparelho precisa ser do ACS e + /// do escopo de quem pede; qualquer outro caso recebe a mesma recusa. Repetir + /// é idempotente (`alreadyOrdered`, carimbo original). Audita + /// `admin_device`/`wipe_ordered` com o id do pedido. + _i2.Future<_i21.AdminDeviceWipeResult> orderDeviceWipe({ + required String accessToken, + required String acsId, + required String deviceId, + required _i22.DeviceWipeReason reason, + }) => caller.callServerEndpoint<_i21.AdminDeviceWipeResult>( + 'admin', + 'orderDeviceWipe', + { + 'accessToken': accessToken, + 'acsId': acsId, + 'deviceId': deviceId, + 'reason': reason, + }, + ); + /// Escalona um alerta vermelho pendente cujo prazo sem confirmação venceu /// (#44). Grava data, autor e motivo fechado, reenfileira **uma** entrega de /// reforço para o tópico da microárea e audita — numa transação só; o alerta /// segue pendente. Repetir é idempotente (`alreadyEscalated`, carimbo /// original). A resposta não traz identidade: só id, carimbo e o indicador. - _i2.Future<_i20.AdminEscalationResult> escalateAlert({ + _i2.Future<_i23.AdminEscalationResult> escalateAlert({ required String accessToken, required String alertId, - required _i21.EscalationReason reason, - }) => caller.callServerEndpoint<_i20.AdminEscalationResult>( + required _i24.EscalationReason reason, + }) => caller.callServerEndpoint<_i23.AdminEscalationResult>( 'admin', 'escalateAlert', { @@ -478,12 +519,12 @@ class EndpointAdmin extends EndpointAuthenticated { /// Histórico de avisos, mais recentes primeiro, **sem o texto** (#46). /// Coordenador: só a própria UBS. - _i2.Future<_i22.NoticeHistoryPage> noticeHistory({ + _i2.Future<_i25.NoticeHistoryPage> noticeHistory({ required String accessToken, String? microAreaId, required int limit, required int offset, - }) => caller.callServerEndpoint<_i22.NoticeHistoryPage>( + }) => caller.callServerEndpoint<_i25.NoticeHistoryPage>( 'admin', 'noticeHistory', { @@ -495,21 +536,21 @@ class EndpointAdmin extends EndpointAuthenticated { ); /// Versões dos termos, mais recentes primeiro. - _i2.Future> termsVersions({ + _i2.Future> termsVersions({ required String accessToken, - }) => caller.callServerEndpoint>( + }) => caller.callServerEndpoint>( 'admin', 'termsVersions', {'accessToken': accessToken}, ); /// Rascunha uma versão dos termos. - _i2.Future<_i23.TermsVersionEntry> draftTermsVersion({ + _i2.Future<_i26.TermsVersionEntry> draftTermsVersion({ required String accessToken, required String version, required String summary, required DateTime effectiveFrom, - }) => caller.callServerEndpoint<_i23.TermsVersionEntry>( + }) => caller.callServerEndpoint<_i26.TermsVersionEntry>( 'admin', 'draftTermsVersion', { @@ -521,10 +562,10 @@ class EndpointAdmin extends EndpointAuthenticated { ); /// Registra a revisão jurídica de uma versão. - _i2.Future<_i23.TermsVersionEntry> markTermsReviewed({ + _i2.Future<_i26.TermsVersionEntry> markTermsReviewed({ required String accessToken, required String id, - }) => caller.callServerEndpoint<_i23.TermsVersionEntry>( + }) => caller.callServerEndpoint<_i26.TermsVersionEntry>( 'admin', 'markTermsReviewed', { @@ -534,10 +575,10 @@ class EndpointAdmin extends EndpointAuthenticated { ); /// Publica uma versão revisada, com vigência a pelo menos 15 dias. - _i2.Future<_i23.TermsVersionEntry> publishTermsVersion({ + _i2.Future<_i26.TermsVersionEntry> publishTermsVersion({ required String accessToken, required String id, - }) => caller.callServerEndpoint<_i23.TermsVersionEntry>( + }) => caller.callServerEndpoint<_i26.TermsVersionEntry>( 'admin', 'publishTermsVersion', { @@ -568,12 +609,12 @@ class EndpointAlerts extends EndpointAuthenticated { @override String get name => 'alerts'; - _i2.Future<_i24.RedAlertResult> createRedAlert({ + _i2.Future<_i27.RedAlertResult> createRedAlert({ required String accessToken, required String idempotencyKey, required String locationHash, String? locationCell, - }) => caller.callServerEndpoint<_i24.RedAlertResult>( + }) => caller.callServerEndpoint<_i27.RedAlertResult>( 'alerts', 'createRedAlert', { @@ -584,10 +625,10 @@ class EndpointAlerts extends EndpointAuthenticated { }, ); - _i2.Future<_i25.AlertAckResult> acknowledge({ + _i2.Future<_i28.AlertAckResult> acknowledge({ required String accessToken, required String alertId, - }) => caller.callServerEndpoint<_i25.AlertAckResult>( + }) => caller.callServerEndpoint<_i28.AlertAckResult>( 'alerts', 'acknowledge', { @@ -601,10 +642,10 @@ class EndpointAlerts extends EndpointAuthenticated { /// devolve `resolved: false`, como o ACK; alerta ainda não confirmado é /// [AlertValidationException] com o texto de /// `RedAlertService.notAcknowledgedMessage`. - _i2.Future<_i26.AlertResolveResult> resolve({ + _i2.Future<_i29.AlertResolveResult> resolve({ required String accessToken, required String alertId, - }) => caller.callServerEndpoint<_i26.AlertResolveResult>( + }) => caller.callServerEndpoint<_i29.AlertResolveResult>( 'alerts', 'resolve', { @@ -618,9 +659,9 @@ class EndpointAlerts extends EndpointAuthenticated { /// token (INV-01). Não grava linha de auditoria de leitura: o app repete a /// consulta a cada 30 s com o broker fora, e o mesmo conteúdo chega pelo /// MQTT sem auditoria; ACK e conclusão seguem auditados. - _i2.Future> openForMicroArea({ + _i2.Future> openForMicroArea({ required String accessToken, - }) => caller.callServerEndpoint>( + }) => caller.callServerEndpoint>( 'alerts', 'openForMicroArea', {'accessToken': accessToken}, @@ -628,8 +669,8 @@ class EndpointAlerts extends EndpointAuthenticated { /// Status do alerta mais recente do PRÓPRIO paciente (RF05, decisão §5). /// `patientId` nunca é parâmetro — vem do token (INV-05). - _i2.Future<_i28.AlertStatusResult> statusFor({required String accessToken}) => - caller.callServerEndpoint<_i28.AlertStatusResult>( + _i2.Future<_i31.AlertStatusResult> statusFor({required String accessToken}) => + caller.callServerEndpoint<_i31.AlertStatusResult>( 'alerts', 'statusFor', {'accessToken': accessToken}, @@ -651,9 +692,9 @@ class EndpointAuth extends _i1.EndpointRef { @override String get name => 'auth'; - _i2.Future<_i29.DevelopmentLoginResult> developmentLogin({ + _i2.Future<_i32.DevelopmentLoginResult> developmentLogin({ required String role, - }) => caller.callServerEndpoint<_i29.DevelopmentLoginResult>( + }) => caller.callServerEndpoint<_i32.DevelopmentLoginResult>( 'auth', 'developmentLogin', {'role': role}, @@ -671,12 +712,12 @@ class EndpointAuth extends _i1.EndpointRef { /// [totpCode] recebe `MfaRequiredException` (o app pede o código e repete a /// chamada); com `REQUIRE_ACS_MFA` ligado e a MFA não ativada, recebe /// `MfaEnrollmentRequiredException` (o app chama `beginTotpEnrollment`). - _i2.Future<_i29.DevelopmentLoginResult> loginInstitutional({ + _i2.Future<_i32.DevelopmentLoginResult> loginInstitutional({ required String matricula, required String password, String? deviceId, String? totpCode, - }) => caller.callServerEndpoint<_i29.DevelopmentLoginResult>( + }) => caller.callServerEndpoint<_i32.DevelopmentLoginResult>( 'auth', 'loginInstitutional', { @@ -697,11 +738,11 @@ class EndpointAuth extends _i1.EndpointRef { /// As recusas são as de [loginInstitutional]: mensagem única para matrícula /// inexistente e senha errada, `MfaRequiredException` sem o código, e o mesmo /// bloqueio por tentativas. - _i2.Future<_i29.DevelopmentLoginResult> loginStaff({ + _i2.Future<_i32.DevelopmentLoginResult> loginStaff({ required String matricula, required String password, String? totpCode, - }) => caller.callServerEndpoint<_i29.DevelopmentLoginResult>( + }) => caller.callServerEndpoint<_i32.DevelopmentLoginResult>( 'auth', 'loginStaff', { @@ -716,11 +757,11 @@ class EndpointAuth extends _i1.EndpointRef { /// por `bin/issue_staff_activation_code.dart`; a MFA é obrigatória, então não /// há login possível antes dela. Código errado, expirado ou já usado recebe a /// mesma recusa e conta como tentativa falha da conta. - _i2.Future<_i30.TotpEnrollmentStart> beginStaffTotpEnrollment({ + _i2.Future<_i33.TotpEnrollmentStart> beginStaffTotpEnrollment({ required String matricula, required String password, required String activationCode, - }) => caller.callServerEndpoint<_i30.TotpEnrollmentStart>( + }) => caller.callServerEndpoint<_i33.TotpEnrollmentStart>( 'auth', 'beginStaffTotpEnrollment', { @@ -750,10 +791,10 @@ class EndpointAuth extends _i1.EndpointRef { /// Renova a sessão do ACS sem pedir senha nem TOTP (LGPD-RT06). Público por /// desenho: quem chama já perdeu o JWT de 15 min — o refresh token, opaco e de /// uso único, é a credencial. Toda recusa é a mesma `SessionExpiredException`. - _i2.Future<_i29.DevelopmentLoginResult> refreshSession({ + _i2.Future<_i32.DevelopmentLoginResult> refreshSession({ required String refreshToken, required String deviceId, - }) => caller.callServerEndpoint<_i29.DevelopmentLoginResult>( + }) => caller.callServerEndpoint<_i32.DevelopmentLoginResult>( 'auth', 'refreshSession', { @@ -771,10 +812,10 @@ class EndpointAuth extends _i1.EndpointRef { ); /// Começa a ativação da MFA do ACS (RF07). Sem token: o ACS prova matrícula e senha. - _i2.Future<_i30.TotpEnrollmentStart> beginTotpEnrollment({ + _i2.Future<_i33.TotpEnrollmentStart> beginTotpEnrollment({ required String matricula, required String password, - }) => caller.callServerEndpoint<_i30.TotpEnrollmentStart>( + }) => caller.callServerEndpoint<_i33.TotpEnrollmentStart>( 'auth', 'beginTotpEnrollment', { @@ -839,11 +880,11 @@ class EndpointAuth extends _i1.EndpointRef { /// /// Toda recusa chega ao app como `OtpRequestException`, com a mensagem que o /// serviço escolheu: uma só, para não dizer se aquele CPF existe. - _i2.Future<_i29.DevelopmentLoginResult> verifyOtp({ + _i2.Future<_i32.DevelopmentLoginResult> verifyOtp({ required String cpf, required String code, String? deviceId, - }) => caller.callServerEndpoint<_i29.DevelopmentLoginResult>( + }) => caller.callServerEndpoint<_i32.DevelopmentLoginResult>( 'auth', 'verifyOtp', { @@ -910,8 +951,8 @@ class EndpointHealth extends _i1.EndpointRef { @override String get name => 'health'; - _i2.Future<_i31.ServiceHealth> check() => - caller.callServerEndpoint<_i31.ServiceHealth>( + _i2.Future<_i34.ServiceHealth> check() => + caller.callServerEndpoint<_i34.ServiceHealth>( 'health', 'check', {}, @@ -930,12 +971,12 @@ class EndpointNotices extends EndpointAuthenticated { /// consentiram com `segmentedPush`. A microárea vem do token, nunca de /// parâmetro. Auditoria: uma linha `community_notice` com quem enviou e a /// microárea; o texto do aviso nunca entra na trilha. - _i2.Future<_i32.NoticeSendResult> sendSegmented({ + _i2.Future<_i35.NoticeSendResult> sendSegmented({ required String accessToken, required String title, required String message, required String audience, - }) => caller.callServerEndpoint<_i32.NoticeSendResult>( + }) => caller.callServerEndpoint<_i35.NoticeSendResult>( 'notices', 'sendSegmented', { @@ -964,10 +1005,10 @@ class EndpointOnboarding extends _i1.EndpointRef { String get name => 'onboarding'; /// Chamado pelo app do ACS. Exige sessão de ACS. - _i2.Future<_i33.EnrollmentTokenResult> generateEnrollmentToken({ + _i2.Future<_i36.EnrollmentTokenResult> generateEnrollmentToken({ required String accessToken, required String patientId, - }) => caller.callServerEndpoint<_i33.EnrollmentTokenResult>( + }) => caller.callServerEndpoint<_i36.EnrollmentTokenResult>( 'onboarding', 'generateEnrollmentToken', { @@ -978,13 +1019,13 @@ class EndpointOnboarding extends _i1.EndpointRef { /// Chamado pelo app do paciente. Não exige sessão prévia — é a própria /// conclusão do onboarding que emite a primeira sessão. - _i2.Future<_i34.EnrollmentResult> completeEnrollment({ + _i2.Future<_i37.EnrollmentResult> completeEnrollment({ required String token, required bool healthDataConsent, required bool remindersConsent, required bool pushConsent, required bool termsAccepted, - }) => caller.callServerEndpoint<_i34.EnrollmentResult>( + }) => caller.callServerEndpoint<_i37.EnrollmentResult>( 'onboarding', 'completeEnrollment', { @@ -1013,9 +1054,9 @@ class EndpointPatients extends EndpointAuthenticated { @override String get name => 'patients'; - _i2.Future> listMicroArea({ + _i2.Future> listMicroArea({ required String accessToken, - }) => caller.callServerEndpoint>( + }) => caller.callServerEndpoint>( 'patients', 'listMicroArea', {'accessToken': accessToken}, @@ -1048,8 +1089,8 @@ class EndpointPatients extends EndpointAuthenticated { /// Painel "Meus Dados" do próprio paciente autenticado (LGPD, /// spec/lgpd_design.md linhas 417/581-595): confirmação de existência de /// tratamento e acesso aos dados pessoais. - _i2.Future<_i36.PatientDataOverview> myData({required String accessToken}) => - caller.callServerEndpoint<_i36.PatientDataOverview>( + _i2.Future<_i39.PatientDataOverview> myData({required String accessToken}) => + caller.callServerEndpoint<_i39.PatientDataOverview>( 'patients', 'myData', {'accessToken': accessToken}, @@ -1059,11 +1100,11 @@ class EndpointPatients extends EndpointAuthenticated { /// consentimento (LGPD-RF05): uma linha nova em `consent_logs`, nunca a /// edição da anterior. `healthDataProcessing` volta como /// [DataRightsException] — retirá-lo passa pelo pedido de exclusão. - _i2.Future<_i37.PatientConsentRecord> updateConsent({ + _i2.Future<_i40.PatientConsentRecord> updateConsent({ required String accessToken, - required _i38.ConsentPurpose purpose, + required _i41.ConsentPurpose purpose, required bool granted, - }) => caller.callServerEndpoint<_i37.PatientConsentRecord>( + }) => caller.callServerEndpoint<_i40.PatientConsentRecord>( 'patients', 'updateConsent', { @@ -1077,9 +1118,9 @@ class EndpointPatients extends EndpointAuthenticated { /// por quem entrou por OTP sem passar pelo onboarding, ou aceitou uma versão /// anterior. Só paciente; grava uma linha assinada em `consent_logs` só se a /// versão vigente ainda não foi aceita — repetir devolve a existente. - _i2.Future<_i37.PatientConsentRecord> acceptTermsOfUse({ + _i2.Future<_i40.PatientConsentRecord> acceptTermsOfUse({ required String accessToken, - }) => caller.callServerEndpoint<_i37.PatientConsentRecord>( + }) => caller.callServerEndpoint<_i40.PatientConsentRecord>( 'patients', 'acceptTermsOfUse', {'accessToken': accessToken}, @@ -1103,7 +1144,7 @@ class EndpointPatients extends EndpointAuthenticated { /// push (RF14). _i2.Future hasGrantedConsent({ required String accessToken, - required _i38.ConsentPurpose purpose, + required _i41.ConsentPurpose purpose, }) => caller.callServerEndpoint( 'patients', 'hasGrantedConsent', @@ -1116,9 +1157,9 @@ class EndpointPatients extends EndpointAuthenticated { /// Aviso de mudança dos termos ativo agora (LGPD-RF18, 15 dias de antecedência), /// ou `null`. Só paciente. Lê a agenda publicada em `terms_versions` e não grava /// linha de auditoria: nada do titular é lido nem gravado. - _i2.Future<_i39.TermsChangeNotice?> termsChangeNotice({ + _i2.Future<_i42.TermsChangeNotice?> termsChangeNotice({ required String accessToken, - }) => caller.callServerEndpoint<_i39.TermsChangeNotice?>( + }) => caller.callServerEndpoint<_i42.TermsChangeNotice?>( 'patients', 'termsChangeNotice', {'accessToken': accessToken}, @@ -1126,9 +1167,9 @@ class EndpointPatients extends EndpointAuthenticated { /// Pedido de exclusão/anonimização dos próprios dados (LGPD-RF08). /// Idempotente enquanto houver um pedido de exclusão em aberto. - _i2.Future<_i40.PatientDataSubjectRequestRecord> requestDataDeletion({ + _i2.Future<_i43.PatientDataSubjectRequestRecord> requestDataDeletion({ required String accessToken, - }) => caller.callServerEndpoint<_i40.PatientDataSubjectRequestRecord>( + }) => caller.callServerEndpoint<_i43.PatientDataSubjectRequestRecord>( 'patients', 'requestDataDeletion', {'accessToken': accessToken}, @@ -1137,10 +1178,10 @@ class EndpointPatients extends EndpointAuthenticated { /// Pedido de correção de um dado (LGPD-RF08). [details] é texto livre do /// titular, gravado cifrado; vazio ou acima de 500 caracteres volta como /// [DataRightsException]. - _i2.Future<_i40.PatientDataSubjectRequestRecord> requestDataCorrection({ + _i2.Future<_i43.PatientDataSubjectRequestRecord> requestDataCorrection({ required String accessToken, required String details, - }) => caller.callServerEndpoint<_i40.PatientDataSubjectRequestRecord>( + }) => caller.callServerEndpoint<_i43.PatientDataSubjectRequestRecord>( 'patients', 'requestDataCorrection', { @@ -1167,7 +1208,7 @@ class EndpointTriage extends EndpointAuthenticated { @override String get name => 'triage'; - _i2.Future<_i41.TriageResult> evaluate({ + _i2.Future<_i44.TriageResult> evaluate({ required String accessToken, required bool chestPain, required bool difficultyBreathing, @@ -1175,7 +1216,7 @@ class EndpointTriage extends EndpointAuthenticated { required bool persistentVomiting, required bool bleeding, required bool severeWeakness, - }) => caller.callServerEndpoint<_i41.TriageResult>( + }) => caller.callServerEndpoint<_i44.TriageResult>( 'triage', 'evaluate', { @@ -1198,8 +1239,8 @@ class EndpointUbs extends EndpointAuthenticated { @override String get name => 'ubs'; - _i2.Future<_i42.UbsContact> myContact({required String accessToken}) => - caller.callServerEndpoint<_i42.UbsContact>( + _i2.Future<_i45.UbsContact> myContact({required String accessToken}) => + caller.callServerEndpoint<_i45.UbsContact>( 'ubs', 'myContact', {'accessToken': accessToken}, @@ -1222,10 +1263,10 @@ class EndpointVisits extends EndpointAuthenticated { @override String get name => 'visits'; - _i2.Future> sync({ + _i2.Future> sync({ required String accessToken, - required List<_i44.VisitSyncEntry> visits, - }) => caller.callServerEndpoint>( + required List<_i47.VisitSyncEntry> visits, + }) => caller.callServerEndpoint>( 'visits', 'sync', { @@ -1239,11 +1280,11 @@ class EndpointVisits extends EndpointAuthenticated { /// conflito. `visit.version` é ignorado. Ver /// `VisitSyncService.resolveConflict`. Mesma transação e mesma tradução de /// erros de [sync]. - _i2.Future<_i43.VisitSyncResult> resolveConflict({ + _i2.Future<_i46.VisitSyncResult> resolveConflict({ required String accessToken, - required _i44.VisitSyncEntry visit, + required _i47.VisitSyncEntry visit, required int expectedServerVersion, - }) => caller.callServerEndpoint<_i43.VisitSyncResult>( + }) => caller.callServerEndpoint<_i46.VisitSyncResult>( 'visits', 'resolveConflict', { @@ -1258,11 +1299,11 @@ class EndpointVisits extends EndpointAuthenticated { /// /// A sessão do ACS é só o transporte — ver `VisitSyncService.syncLegacy`. /// Mesma transação por lote e mesma tradução de erros de [sync]. - _i2.Future> syncLegacy({ + _i2.Future> syncLegacy({ required String accessToken, required String deviceId, - required List<_i44.VisitSyncEntry> visits, - }) => caller.callServerEndpoint>( + required List<_i47.VisitSyncEntry> visits, + }) => caller.callServerEndpoint>( 'visits', 'syncLegacy', { @@ -1286,11 +1327,11 @@ class EndpointVisits extends EndpointAuthenticated { /// No máximo `VisitSyncService.maxLegacyBatch` visitas por chamada, checado /// antes de qualquer acesso ao banco. Um evento `visit_deferred_sync` por /// lote gravado vai para `audit_logs`, com o dono e nada clínico. - _i2.Future> syncDeferred({ + _i2.Future> syncDeferred({ required String uploadToken, required String deviceId, - required List<_i44.VisitSyncEntry> visits, - }) => caller.callServerEndpoint>( + required List<_i47.VisitSyncEntry> visits, + }) => caller.callServerEndpoint>( 'visits', 'syncDeferred', { @@ -1313,10 +1354,10 @@ class EndpointVisits extends EndpointAuthenticated { /// Sincronização central→dispositivo: visitas da microárea do ACS /// autenticado alteradas após `since`, para reconciliar um device que /// ficou offline ou foi reinstalado. - _i2.Future> pull({ + _i2.Future> pull({ required String accessToken, required DateTime since, - }) => caller.callServerEndpoint>( + }) => caller.callServerEndpoint>( 'visits', 'pull', { @@ -1346,7 +1387,7 @@ class Client extends _i1.ServerpodClientShared { bool? disconnectStreamsOnLostInternetConnection, }) : super( host, - _i45.Protocol(), + _i48.Protocol(), securityContext: securityContext, streamingConnectionTimeout: streamingConnectionTimeout, connectionTimeout: connectionTimeout, diff --git a/backend/sinalacs_client/lib/src/protocol/device_wipe_order.dart b/backend/sinalacs_client/lib/src/protocol/device_wipe_order.dart new file mode 100644 index 0000000..f02e713 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/device_wipe_order.dart @@ -0,0 +1,181 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod_client/serverpod_client.dart' as _i1; +import 'enums/device_wipe_reason.dart' as _i2; + +/// Pedido de limpeza remota do aparelho de um ACS (#81). Um por aparelho +/// (índice único em `deviceId`): pedir de novo devolve o pedido original. +/// +/// O pedido não carrega dado pessoal: só ids, o motivo (conjunto fechado) e os +/// carimbos. `deliveredAt` marca a primeira vez que o aparelho foi mandado +/// limpar; `confirmedAt`, a confirmação do próprio aparelho de que limpou. +abstract class DeviceWipeOrder implements _i1.SerializableModel { + DeviceWipeOrder._({ + this.id, + required this.deviceId, + required this.targetUserId, + required this.requestedBy, + required this.reason, + required this.requestedAt, + this.deliveredAt, + this.confirmedAt, + }); + + factory DeviceWipeOrder({ + _i1.UuidValue? id, + required String deviceId, + required _i1.UuidValue targetUserId, + required _i1.UuidValue requestedBy, + required _i2.DeviceWipeReason reason, + required DateTime requestedAt, + DateTime? deliveredAt, + DateTime? confirmedAt, + }) = _DeviceWipeOrderImpl; + + factory DeviceWipeOrder.fromJson(Map jsonSerialization) { + return DeviceWipeOrder( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + deviceId: jsonSerialization['deviceId'] as String, + targetUserId: _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['targetUserId'], + ), + requestedBy: _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['requestedBy'], + ), + reason: _i2.DeviceWipeReason.fromJson( + (jsonSerialization['reason'] as String), + ), + requestedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['requestedAt'], + ), + deliveredAt: jsonSerialization['deliveredAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['deliveredAt'], + ), + confirmedAt: jsonSerialization['confirmedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['confirmedAt'], + ), + ); + } + + /// The database id, set if the object has been inserted into the + /// database or if it has been fetched from the database. Otherwise, + /// the id will be null. + _i1.UuidValue? id; + + /// Instalação alvo (`acs_refresh_tokens.deviceId`). + String deviceId; + + /// O ACS dono do aparelho no momento do pedido. + _i1.UuidValue targetUserId; + + /// Quem pediu (coordenador ou administrador). + _i1.UuidValue requestedBy; + + _i2.DeviceWipeReason reason; + + DateTime requestedAt; + + DateTime? deliveredAt; + + DateTime? confirmedAt; + + /// Returns a shallow copy of this [DeviceWipeOrder] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + DeviceWipeOrder copyWith({ + _i1.UuidValue? id, + String? deviceId, + _i1.UuidValue? targetUserId, + _i1.UuidValue? requestedBy, + _i2.DeviceWipeReason? reason, + DateTime? requestedAt, + DateTime? deliveredAt, + DateTime? confirmedAt, + }); + @override + Map toJson() { + return { + '__className__': 'DeviceWipeOrder', + if (id != null) 'id': id?.toJson(), + 'deviceId': deviceId, + 'targetUserId': targetUserId.toJson(), + 'requestedBy': requestedBy.toJson(), + 'reason': reason.toJson(), + 'requestedAt': requestedAt.toJson(), + if (deliveredAt != null) 'deliveredAt': deliveredAt?.toJson(), + if (confirmedAt != null) 'confirmedAt': confirmedAt?.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _DeviceWipeOrderImpl extends DeviceWipeOrder { + _DeviceWipeOrderImpl({ + _i1.UuidValue? id, + required String deviceId, + required _i1.UuidValue targetUserId, + required _i1.UuidValue requestedBy, + required _i2.DeviceWipeReason reason, + required DateTime requestedAt, + DateTime? deliveredAt, + DateTime? confirmedAt, + }) : super._( + id: id, + deviceId: deviceId, + targetUserId: targetUserId, + requestedBy: requestedBy, + reason: reason, + requestedAt: requestedAt, + deliveredAt: deliveredAt, + confirmedAt: confirmedAt, + ); + + /// Returns a shallow copy of this [DeviceWipeOrder] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + DeviceWipeOrder copyWith({ + Object? id = _Undefined, + String? deviceId, + _i1.UuidValue? targetUserId, + _i1.UuidValue? requestedBy, + _i2.DeviceWipeReason? reason, + DateTime? requestedAt, + Object? deliveredAt = _Undefined, + Object? confirmedAt = _Undefined, + }) { + return DeviceWipeOrder( + id: id is _i1.UuidValue? ? id : this.id, + deviceId: deviceId ?? this.deviceId, + targetUserId: targetUserId ?? this.targetUserId, + requestedBy: requestedBy ?? this.requestedBy, + reason: reason ?? this.reason, + requestedAt: requestedAt ?? this.requestedAt, + deliveredAt: deliveredAt is DateTime? ? deliveredAt : this.deliveredAt, + confirmedAt: confirmedAt is DateTime? ? confirmedAt : this.confirmedAt, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/enums/device_wipe_reason.dart b/backend/sinalacs_client/lib/src/protocol/enums/device_wipe_reason.dart new file mode 100644 index 0000000..5061e95 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/enums/device_wipe_reason.dart @@ -0,0 +1,44 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod_client/serverpod_client.dart' as _i1; + +/// Motivo da limpeza remota de um aparelho (#81). Conjunto fechado, sem texto +/// livre: o motivo vai para a trilha de auditoria e texto livre ali seria um +/// canal para dado pessoal. +enum DeviceWipeReason implements _i1.SerializableModel { + lost, + stolen, + replaced; + + static DeviceWipeReason fromJson(String name) { + switch (name) { + case 'lost': + return DeviceWipeReason.lost; + case 'stolen': + return DeviceWipeReason.stolen; + case 'replaced': + return DeviceWipeReason.replaced; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "DeviceWipeReason"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_client/lib/src/protocol/enums/device_wipe_state.dart b/backend/sinalacs_client/lib/src/protocol/enums/device_wipe_state.dart new file mode 100644 index 0000000..49002e4 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/enums/device_wipe_state.dart @@ -0,0 +1,44 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod_client/serverpod_client.dart' as _i1; + +/// Estado do pedido de limpeza de um aparelho (#81), derivado dos carimbos do +/// pedido: `pending` (pedido, aparelho ainda não mandado limpar), `delivered` +/// (ordem entregue ao aparelho) e `confirmed` (aparelho confirmou a limpeza). +enum DeviceWipeState implements _i1.SerializableModel { + pending, + delivered, + confirmed; + + static DeviceWipeState fromJson(String name) { + switch (name) { + case 'pending': + return DeviceWipeState.pending; + case 'delivered': + return DeviceWipeState.delivered; + case 'confirmed': + return DeviceWipeState.confirmed; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "DeviceWipeState"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_client/lib/src/protocol/protocol.dart b/backend/sinalacs_client/lib/src/protocol/protocol.dart index 2bc995e..3d844d8 100644 --- a/backend/sinalacs_client/lib/src/protocol/protocol.dart +++ b/backend/sinalacs_client/lib/src/protocol/protocol.dart @@ -22,104 +22,112 @@ import 'alert_outbox_entry.dart' as _i8; import 'api/acs_open_alert.dart' as _i9; import 'api/admin_acs.dart' as _i10; import 'api/admin_acs_creation_result.dart' as _i11; -import 'api/admin_alert.dart' as _i12; -import 'api/admin_alert_page.dart' as _i13; -import 'api/admin_alerts_pulse.dart' as _i14; -import 'api/admin_audit_chain_status.dart' as _i15; -import 'api/admin_audit_entry.dart' as _i16; -import 'api/admin_audit_export.dart' as _i17; -import 'api/admin_audit_filter.dart' as _i18; -import 'api/admin_audit_page.dart' as _i19; -import 'api/admin_data_subject_request.dart' as _i20; -import 'api/admin_data_subject_request_detail.dart' as _i21; -import 'api/admin_data_subject_request_page.dart' as _i22; -import 'api/admin_escalation_result.dart' as _i23; -import 'api/admin_indicators.dart' as _i24; -import 'api/admin_micro_area.dart' as _i25; -import 'api/admin_password_reset_result.dart' as _i26; -import 'api/admin_staff.dart' as _i27; -import 'api/admin_staff_mfa_reset_result.dart' as _i28; -import 'api/alert_ack_result.dart' as _i29; -import 'api/alert_resolve_result.dart' as _i30; -import 'api/alert_status_result.dart' as _i31; -import 'api/development_login_result.dart' as _i32; -import 'api/enrollment_result.dart' as _i33; -import 'api/enrollment_token_result.dart' as _i34; -import 'api/micro_area_patient.dart' as _i35; -import 'api/notice_history_entry.dart' as _i36; -import 'api/notice_history_page.dart' as _i37; -import 'api/notice_send_result.dart' as _i38; -import 'api/patient_consent_record.dart' as _i39; -import 'api/patient_data_overview.dart' as _i40; -import 'api/patient_data_subject_request_record.dart' as _i41; -import 'api/patient_risk_event.dart' as _i42; -import 'api/red_alert_result.dart' as _i43; -import 'api/service_health.dart' as _i44; -import 'api/terms_change_notice.dart' as _i45; -import 'api/terms_version_entry.dart' as _i46; -import 'api/totp_enrollment_start.dart' as _i47; -import 'api/triage_result.dart' as _i48; -import 'api/ubs_contact.dart' as _i49; -import 'api/visit_sync_entry.dart' as _i50; -import 'api/visit_sync_result.dart' as _i51; -import 'audit_chain_checkpoint.dart' as _i52; -import 'audit_export.dart' as _i53; -import 'audit_log.dart' as _i54; -import 'consent_log.dart' as _i55; -import 'data_subject_request.dart' as _i56; -import 'enrollment_token.dart' as _i57; -import 'enums/alert_status.dart' as _i58; -import 'enums/arrival_method.dart' as _i59; -import 'enums/audit_chain_reason.dart' as _i60; -import 'enums/audit_integrity_state.dart' as _i61; -import 'enums/audit_row_seal.dart' as _i62; -import 'enums/consent_purpose.dart' as _i63; -import 'enums/data_subject_request_status.dart' as _i64; -import 'enums/data_subject_request_type.dart' as _i65; -import 'enums/escalation_reason.dart' as _i66; -import 'enums/risk_level.dart' as _i67; -import 'enums/scheduled_notice_status.dart' as _i68; -import 'enums/sync_status.dart' as _i69; -import 'enums/user_role.dart' as _i70; -import 'enums/visit_authorship.dart' as _i71; -import 'exceptions/admin_invalid_request_exception.dart' as _i72; -import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i73; -import 'exceptions/alert_permission_exception.dart' as _i74; -import 'exceptions/alert_validation_exception.dart' as _i75; -import 'exceptions/authentication_failed_exception.dart' as _i76; -import 'exceptions/data_rights_exception.dart' as _i77; -import 'exceptions/endpoint_disabled_exception.dart' as _i78; -import 'exceptions/enrollment_exception.dart' as _i79; -import 'exceptions/mfa_enrollment_required_exception.dart' as _i80; -import 'exceptions/mfa_required_exception.dart' as _i81; -import 'exceptions/notice_delivery_exception.dart' as _i82; -import 'exceptions/otp_request_exception.dart' as _i83; -import 'exceptions/session_expired_exception.dart' as _i84; -import 'micro_area.dart' as _i85; -import 'otp_challenge.dart' as _i86; -import 'patient.dart' as _i87; -import 'push_token.dart' as _i88; -import 'scheduled_notice.dart' as _i89; -import 'staff_account.dart' as _i90; -import 'terms_version.dart' as _i91; -import 'triage_answer.dart' as _i92; -import 'triage_session.dart' as _i93; -import 'ubs.dart' as _i94; -import 'user.dart' as _i95; -import 'user_credential.dart' as _i96; -import 'visit.dart' as _i97; -import 'package:sinalacs_client/src/protocol/api/admin_micro_area.dart' as _i98; -import 'package:sinalacs_client/src/protocol/api/admin_acs.dart' as _i99; -import 'package:sinalacs_client/src/protocol/api/admin_staff.dart' as _i100; +import 'api/admin_acs_device.dart' as _i12; +import 'api/admin_alert.dart' as _i13; +import 'api/admin_alert_page.dart' as _i14; +import 'api/admin_alerts_pulse.dart' as _i15; +import 'api/admin_audit_chain_status.dart' as _i16; +import 'api/admin_audit_entry.dart' as _i17; +import 'api/admin_audit_export.dart' as _i18; +import 'api/admin_audit_filter.dart' as _i19; +import 'api/admin_audit_page.dart' as _i20; +import 'api/admin_data_subject_request.dart' as _i21; +import 'api/admin_data_subject_request_detail.dart' as _i22; +import 'api/admin_data_subject_request_page.dart' as _i23; +import 'api/admin_device_wipe_result.dart' as _i24; +import 'api/admin_escalation_result.dart' as _i25; +import 'api/admin_indicators.dart' as _i26; +import 'api/admin_micro_area.dart' as _i27; +import 'api/admin_password_reset_result.dart' as _i28; +import 'api/admin_staff.dart' as _i29; +import 'api/admin_staff_mfa_reset_result.dart' as _i30; +import 'api/alert_ack_result.dart' as _i31; +import 'api/alert_resolve_result.dart' as _i32; +import 'api/alert_status_result.dart' as _i33; +import 'api/development_login_result.dart' as _i34; +import 'api/enrollment_result.dart' as _i35; +import 'api/enrollment_token_result.dart' as _i36; +import 'api/micro_area_patient.dart' as _i37; +import 'api/notice_history_entry.dart' as _i38; +import 'api/notice_history_page.dart' as _i39; +import 'api/notice_send_result.dart' as _i40; +import 'api/patient_consent_record.dart' as _i41; +import 'api/patient_data_overview.dart' as _i42; +import 'api/patient_data_subject_request_record.dart' as _i43; +import 'api/patient_risk_event.dart' as _i44; +import 'api/red_alert_result.dart' as _i45; +import 'api/service_health.dart' as _i46; +import 'api/terms_change_notice.dart' as _i47; +import 'api/terms_version_entry.dart' as _i48; +import 'api/totp_enrollment_start.dart' as _i49; +import 'api/triage_result.dart' as _i50; +import 'api/ubs_contact.dart' as _i51; +import 'api/visit_sync_entry.dart' as _i52; +import 'api/visit_sync_result.dart' as _i53; +import 'audit_chain_checkpoint.dart' as _i54; +import 'audit_export.dart' as _i55; +import 'audit_log.dart' as _i56; +import 'consent_log.dart' as _i57; +import 'data_subject_request.dart' as _i58; +import 'device_wipe_order.dart' as _i59; +import 'enrollment_token.dart' as _i60; +import 'enums/alert_status.dart' as _i61; +import 'enums/arrival_method.dart' as _i62; +import 'enums/audit_chain_reason.dart' as _i63; +import 'enums/audit_integrity_state.dart' as _i64; +import 'enums/audit_row_seal.dart' as _i65; +import 'enums/consent_purpose.dart' as _i66; +import 'enums/data_subject_request_status.dart' as _i67; +import 'enums/data_subject_request_type.dart' as _i68; +import 'enums/device_wipe_reason.dart' as _i69; +import 'enums/device_wipe_state.dart' as _i70; +import 'enums/escalation_reason.dart' as _i71; +import 'enums/risk_level.dart' as _i72; +import 'enums/scheduled_notice_status.dart' as _i73; +import 'enums/sync_status.dart' as _i74; +import 'enums/user_role.dart' as _i75; +import 'enums/visit_authorship.dart' as _i76; +import 'exceptions/admin_invalid_request_exception.dart' as _i77; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i78; +import 'exceptions/alert_permission_exception.dart' as _i79; +import 'exceptions/alert_validation_exception.dart' as _i80; +import 'exceptions/authentication_failed_exception.dart' as _i81; +import 'exceptions/data_rights_exception.dart' as _i82; +import 'exceptions/endpoint_disabled_exception.dart' as _i83; +import 'exceptions/enrollment_exception.dart' as _i84; +import 'exceptions/mfa_enrollment_required_exception.dart' as _i85; +import 'exceptions/mfa_required_exception.dart' as _i86; +import 'exceptions/notice_delivery_exception.dart' as _i87; +import 'exceptions/otp_request_exception.dart' as _i88; +import 'exceptions/session_expired_exception.dart' as _i89; +import 'micro_area.dart' as _i90; +import 'otp_challenge.dart' as _i91; +import 'patient.dart' as _i92; +import 'push_token.dart' as _i93; +import 'scheduled_notice.dart' as _i94; +import 'staff_account.dart' as _i95; +import 'terms_version.dart' as _i96; +import 'triage_answer.dart' as _i97; +import 'triage_session.dart' as _i98; +import 'ubs.dart' as _i99; +import 'user.dart' as _i100; +import 'user_credential.dart' as _i101; +import 'visit.dart' as _i102; +import 'package:sinalacs_client/src/protocol/api/admin_micro_area.dart' + as _i103; +import 'package:sinalacs_client/src/protocol/api/admin_acs.dart' as _i104; +import 'package:sinalacs_client/src/protocol/api/admin_staff.dart' as _i105; +import 'package:sinalacs_client/src/protocol/api/admin_acs_device.dart' + as _i106; import 'package:sinalacs_client/src/protocol/api/terms_version_entry.dart' - as _i101; -import 'package:sinalacs_client/src/protocol/api/acs_open_alert.dart' as _i102; + as _i107; +import 'package:sinalacs_client/src/protocol/api/acs_open_alert.dart' as _i108; import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' - as _i103; + as _i109; import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' - as _i104; + as _i110; import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' - as _i105; + as _i111; export 'acs.dart'; export 'acs_refresh_token.dart'; export 'acs_upload_token.dart'; @@ -130,6 +138,7 @@ export 'alert_outbox_entry.dart'; export 'api/acs_open_alert.dart'; export 'api/admin_acs.dart'; export 'api/admin_acs_creation_result.dart'; +export 'api/admin_acs_device.dart'; export 'api/admin_alert.dart'; export 'api/admin_alert_page.dart'; export 'api/admin_alerts_pulse.dart'; @@ -141,6 +150,7 @@ export 'api/admin_audit_page.dart'; export 'api/admin_data_subject_request.dart'; export 'api/admin_data_subject_request_detail.dart'; export 'api/admin_data_subject_request_page.dart'; +export 'api/admin_device_wipe_result.dart'; export 'api/admin_escalation_result.dart'; export 'api/admin_indicators.dart'; export 'api/admin_micro_area.dart'; @@ -175,6 +185,7 @@ export 'audit_export.dart'; export 'audit_log.dart'; export 'consent_log.dart'; export 'data_subject_request.dart'; +export 'device_wipe_order.dart'; export 'enrollment_token.dart'; export 'enums/alert_status.dart'; export 'enums/arrival_method.dart'; @@ -184,6 +195,8 @@ export 'enums/audit_row_seal.dart'; export 'enums/consent_purpose.dart'; export 'enums/data_subject_request_status.dart'; export 'enums/data_subject_request_type.dart'; +export 'enums/device_wipe_reason.dart'; +export 'enums/device_wipe_state.dart'; export 'enums/escalation_reason.dart'; export 'enums/risk_level.dart'; export 'enums/scheduled_notice_status.dart'; @@ -282,263 +295,278 @@ class Protocol extends _i1.SerializationManager { if (t == _i11.AdminAcsCreationResult) { return _i11.AdminAcsCreationResult.fromJson(data) as T; } - if (t == _i12.AdminAlert) { - return _i12.AdminAlert.fromJson(data) as T; + if (t == _i12.AdminAcsDevice) { + return _i12.AdminAcsDevice.fromJson(data) as T; + } + if (t == _i13.AdminAlert) { + return _i13.AdminAlert.fromJson(data) as T; + } + if (t == _i14.AdminAlertPage) { + return _i14.AdminAlertPage.fromJson(data) as T; + } + if (t == _i15.AdminAlertsPulse) { + return _i15.AdminAlertsPulse.fromJson(data) as T; + } + if (t == _i16.AdminAuditChainStatus) { + return _i16.AdminAuditChainStatus.fromJson(data) as T; } - if (t == _i13.AdminAlertPage) { - return _i13.AdminAlertPage.fromJson(data) as T; + if (t == _i17.AdminAuditEntry) { + return _i17.AdminAuditEntry.fromJson(data) as T; } - if (t == _i14.AdminAlertsPulse) { - return _i14.AdminAlertsPulse.fromJson(data) as T; + if (t == _i18.AdminAuditExport) { + return _i18.AdminAuditExport.fromJson(data) as T; } - if (t == _i15.AdminAuditChainStatus) { - return _i15.AdminAuditChainStatus.fromJson(data) as T; + if (t == _i19.AdminAuditFilter) { + return _i19.AdminAuditFilter.fromJson(data) as T; } - if (t == _i16.AdminAuditEntry) { - return _i16.AdminAuditEntry.fromJson(data) as T; + if (t == _i20.AdminAuditPage) { + return _i20.AdminAuditPage.fromJson(data) as T; } - if (t == _i17.AdminAuditExport) { - return _i17.AdminAuditExport.fromJson(data) as T; + if (t == _i21.AdminDataSubjectRequest) { + return _i21.AdminDataSubjectRequest.fromJson(data) as T; } - if (t == _i18.AdminAuditFilter) { - return _i18.AdminAuditFilter.fromJson(data) as T; + if (t == _i22.AdminDataSubjectRequestDetail) { + return _i22.AdminDataSubjectRequestDetail.fromJson(data) as T; } - if (t == _i19.AdminAuditPage) { - return _i19.AdminAuditPage.fromJson(data) as T; + if (t == _i23.AdminDataSubjectRequestPage) { + return _i23.AdminDataSubjectRequestPage.fromJson(data) as T; } - if (t == _i20.AdminDataSubjectRequest) { - return _i20.AdminDataSubjectRequest.fromJson(data) as T; + if (t == _i24.AdminDeviceWipeResult) { + return _i24.AdminDeviceWipeResult.fromJson(data) as T; } - if (t == _i21.AdminDataSubjectRequestDetail) { - return _i21.AdminDataSubjectRequestDetail.fromJson(data) as T; + if (t == _i25.AdminEscalationResult) { + return _i25.AdminEscalationResult.fromJson(data) as T; } - if (t == _i22.AdminDataSubjectRequestPage) { - return _i22.AdminDataSubjectRequestPage.fromJson(data) as T; + if (t == _i26.AdminIndicators) { + return _i26.AdminIndicators.fromJson(data) as T; } - if (t == _i23.AdminEscalationResult) { - return _i23.AdminEscalationResult.fromJson(data) as T; + if (t == _i27.AdminMicroArea) { + return _i27.AdminMicroArea.fromJson(data) as T; } - if (t == _i24.AdminIndicators) { - return _i24.AdminIndicators.fromJson(data) as T; + if (t == _i28.AdminPasswordResetResult) { + return _i28.AdminPasswordResetResult.fromJson(data) as T; } - if (t == _i25.AdminMicroArea) { - return _i25.AdminMicroArea.fromJson(data) as T; + if (t == _i29.AdminStaff) { + return _i29.AdminStaff.fromJson(data) as T; } - if (t == _i26.AdminPasswordResetResult) { - return _i26.AdminPasswordResetResult.fromJson(data) as T; + if (t == _i30.AdminStaffMfaResetResult) { + return _i30.AdminStaffMfaResetResult.fromJson(data) as T; } - if (t == _i27.AdminStaff) { - return _i27.AdminStaff.fromJson(data) as T; + if (t == _i31.AlertAckResult) { + return _i31.AlertAckResult.fromJson(data) as T; } - if (t == _i28.AdminStaffMfaResetResult) { - return _i28.AdminStaffMfaResetResult.fromJson(data) as T; + if (t == _i32.AlertResolveResult) { + return _i32.AlertResolveResult.fromJson(data) as T; } - if (t == _i29.AlertAckResult) { - return _i29.AlertAckResult.fromJson(data) as T; + if (t == _i33.AlertStatusResult) { + return _i33.AlertStatusResult.fromJson(data) as T; } - if (t == _i30.AlertResolveResult) { - return _i30.AlertResolveResult.fromJson(data) as T; + if (t == _i34.DevelopmentLoginResult) { + return _i34.DevelopmentLoginResult.fromJson(data) as T; } - if (t == _i31.AlertStatusResult) { - return _i31.AlertStatusResult.fromJson(data) as T; + if (t == _i35.EnrollmentResult) { + return _i35.EnrollmentResult.fromJson(data) as T; } - if (t == _i32.DevelopmentLoginResult) { - return _i32.DevelopmentLoginResult.fromJson(data) as T; + if (t == _i36.EnrollmentTokenResult) { + return _i36.EnrollmentTokenResult.fromJson(data) as T; } - if (t == _i33.EnrollmentResult) { - return _i33.EnrollmentResult.fromJson(data) as T; + if (t == _i37.MicroAreaPatient) { + return _i37.MicroAreaPatient.fromJson(data) as T; } - if (t == _i34.EnrollmentTokenResult) { - return _i34.EnrollmentTokenResult.fromJson(data) as T; + if (t == _i38.NoticeHistoryEntry) { + return _i38.NoticeHistoryEntry.fromJson(data) as T; } - if (t == _i35.MicroAreaPatient) { - return _i35.MicroAreaPatient.fromJson(data) as T; + if (t == _i39.NoticeHistoryPage) { + return _i39.NoticeHistoryPage.fromJson(data) as T; } - if (t == _i36.NoticeHistoryEntry) { - return _i36.NoticeHistoryEntry.fromJson(data) as T; + if (t == _i40.NoticeSendResult) { + return _i40.NoticeSendResult.fromJson(data) as T; } - if (t == _i37.NoticeHistoryPage) { - return _i37.NoticeHistoryPage.fromJson(data) as T; + if (t == _i41.PatientConsentRecord) { + return _i41.PatientConsentRecord.fromJson(data) as T; } - if (t == _i38.NoticeSendResult) { - return _i38.NoticeSendResult.fromJson(data) as T; + if (t == _i42.PatientDataOverview) { + return _i42.PatientDataOverview.fromJson(data) as T; } - if (t == _i39.PatientConsentRecord) { - return _i39.PatientConsentRecord.fromJson(data) as T; + if (t == _i43.PatientDataSubjectRequestRecord) { + return _i43.PatientDataSubjectRequestRecord.fromJson(data) as T; } - if (t == _i40.PatientDataOverview) { - return _i40.PatientDataOverview.fromJson(data) as T; + if (t == _i44.PatientRiskEvent) { + return _i44.PatientRiskEvent.fromJson(data) as T; } - if (t == _i41.PatientDataSubjectRequestRecord) { - return _i41.PatientDataSubjectRequestRecord.fromJson(data) as T; + if (t == _i45.RedAlertResult) { + return _i45.RedAlertResult.fromJson(data) as T; } - if (t == _i42.PatientRiskEvent) { - return _i42.PatientRiskEvent.fromJson(data) as T; + if (t == _i46.ServiceHealth) { + return _i46.ServiceHealth.fromJson(data) as T; } - if (t == _i43.RedAlertResult) { - return _i43.RedAlertResult.fromJson(data) as T; + if (t == _i47.TermsChangeNotice) { + return _i47.TermsChangeNotice.fromJson(data) as T; } - if (t == _i44.ServiceHealth) { - return _i44.ServiceHealth.fromJson(data) as T; + if (t == _i48.TermsVersionEntry) { + return _i48.TermsVersionEntry.fromJson(data) as T; } - if (t == _i45.TermsChangeNotice) { - return _i45.TermsChangeNotice.fromJson(data) as T; + if (t == _i49.TotpEnrollmentStart) { + return _i49.TotpEnrollmentStart.fromJson(data) as T; } - if (t == _i46.TermsVersionEntry) { - return _i46.TermsVersionEntry.fromJson(data) as T; + if (t == _i50.TriageResult) { + return _i50.TriageResult.fromJson(data) as T; } - if (t == _i47.TotpEnrollmentStart) { - return _i47.TotpEnrollmentStart.fromJson(data) as T; + if (t == _i51.UbsContact) { + return _i51.UbsContact.fromJson(data) as T; } - if (t == _i48.TriageResult) { - return _i48.TriageResult.fromJson(data) as T; + if (t == _i52.VisitSyncEntry) { + return _i52.VisitSyncEntry.fromJson(data) as T; } - if (t == _i49.UbsContact) { - return _i49.UbsContact.fromJson(data) as T; + if (t == _i53.VisitSyncResult) { + return _i53.VisitSyncResult.fromJson(data) as T; } - if (t == _i50.VisitSyncEntry) { - return _i50.VisitSyncEntry.fromJson(data) as T; + if (t == _i54.AuditChainCheckpoint) { + return _i54.AuditChainCheckpoint.fromJson(data) as T; } - if (t == _i51.VisitSyncResult) { - return _i51.VisitSyncResult.fromJson(data) as T; + if (t == _i55.AuditExport) { + return _i55.AuditExport.fromJson(data) as T; } - if (t == _i52.AuditChainCheckpoint) { - return _i52.AuditChainCheckpoint.fromJson(data) as T; + if (t == _i56.AuditLog) { + return _i56.AuditLog.fromJson(data) as T; } - if (t == _i53.AuditExport) { - return _i53.AuditExport.fromJson(data) as T; + if (t == _i57.ConsentLog) { + return _i57.ConsentLog.fromJson(data) as T; } - if (t == _i54.AuditLog) { - return _i54.AuditLog.fromJson(data) as T; + if (t == _i58.DataSubjectRequest) { + return _i58.DataSubjectRequest.fromJson(data) as T; } - if (t == _i55.ConsentLog) { - return _i55.ConsentLog.fromJson(data) as T; + if (t == _i59.DeviceWipeOrder) { + return _i59.DeviceWipeOrder.fromJson(data) as T; } - if (t == _i56.DataSubjectRequest) { - return _i56.DataSubjectRequest.fromJson(data) as T; + if (t == _i60.EnrollmentToken) { + return _i60.EnrollmentToken.fromJson(data) as T; } - if (t == _i57.EnrollmentToken) { - return _i57.EnrollmentToken.fromJson(data) as T; + if (t == _i61.AlertStatus) { + return _i61.AlertStatus.fromJson(data) as T; } - if (t == _i58.AlertStatus) { - return _i58.AlertStatus.fromJson(data) as T; + if (t == _i62.ArrivalMethod) { + return _i62.ArrivalMethod.fromJson(data) as T; } - if (t == _i59.ArrivalMethod) { - return _i59.ArrivalMethod.fromJson(data) as T; + if (t == _i63.AuditChainReason) { + return _i63.AuditChainReason.fromJson(data) as T; } - if (t == _i60.AuditChainReason) { - return _i60.AuditChainReason.fromJson(data) as T; + if (t == _i64.AuditIntegrityState) { + return _i64.AuditIntegrityState.fromJson(data) as T; } - if (t == _i61.AuditIntegrityState) { - return _i61.AuditIntegrityState.fromJson(data) as T; + if (t == _i65.AuditRowSeal) { + return _i65.AuditRowSeal.fromJson(data) as T; } - if (t == _i62.AuditRowSeal) { - return _i62.AuditRowSeal.fromJson(data) as T; + if (t == _i66.ConsentPurpose) { + return _i66.ConsentPurpose.fromJson(data) as T; } - if (t == _i63.ConsentPurpose) { - return _i63.ConsentPurpose.fromJson(data) as T; + if (t == _i67.DataSubjectRequestStatus) { + return _i67.DataSubjectRequestStatus.fromJson(data) as T; } - if (t == _i64.DataSubjectRequestStatus) { - return _i64.DataSubjectRequestStatus.fromJson(data) as T; + if (t == _i68.DataSubjectRequestType) { + return _i68.DataSubjectRequestType.fromJson(data) as T; } - if (t == _i65.DataSubjectRequestType) { - return _i65.DataSubjectRequestType.fromJson(data) as T; + if (t == _i69.DeviceWipeReason) { + return _i69.DeviceWipeReason.fromJson(data) as T; } - if (t == _i66.EscalationReason) { - return _i66.EscalationReason.fromJson(data) as T; + if (t == _i70.DeviceWipeState) { + return _i70.DeviceWipeState.fromJson(data) as T; } - if (t == _i67.RiskLevel) { - return _i67.RiskLevel.fromJson(data) as T; + if (t == _i71.EscalationReason) { + return _i71.EscalationReason.fromJson(data) as T; } - if (t == _i68.ScheduledNoticeStatus) { - return _i68.ScheduledNoticeStatus.fromJson(data) as T; + if (t == _i72.RiskLevel) { + return _i72.RiskLevel.fromJson(data) as T; } - if (t == _i69.SyncStatus) { - return _i69.SyncStatus.fromJson(data) as T; + if (t == _i73.ScheduledNoticeStatus) { + return _i73.ScheduledNoticeStatus.fromJson(data) as T; } - if (t == _i70.UserRole) { - return _i70.UserRole.fromJson(data) as T; + if (t == _i74.SyncStatus) { + return _i74.SyncStatus.fromJson(data) as T; } - if (t == _i71.VisitAuthorship) { - return _i71.VisitAuthorship.fromJson(data) as T; + if (t == _i75.UserRole) { + return _i75.UserRole.fromJson(data) as T; } - if (t == _i72.AdminInvalidRequestException) { - return _i72.AdminInvalidRequestException.fromJson(data) as T; + if (t == _i76.VisitAuthorship) { + return _i76.VisitAuthorship.fromJson(data) as T; } - if (t == _i73.AlertDispatchUnavailableException) { - return _i73.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i77.AdminInvalidRequestException) { + return _i77.AdminInvalidRequestException.fromJson(data) as T; } - if (t == _i74.AlertPermissionException) { - return _i74.AlertPermissionException.fromJson(data) as T; + if (t == _i78.AlertDispatchUnavailableException) { + return _i78.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i75.AlertValidationException) { - return _i75.AlertValidationException.fromJson(data) as T; + if (t == _i79.AlertPermissionException) { + return _i79.AlertPermissionException.fromJson(data) as T; } - if (t == _i76.AuthenticationFailedException) { - return _i76.AuthenticationFailedException.fromJson(data) as T; + if (t == _i80.AlertValidationException) { + return _i80.AlertValidationException.fromJson(data) as T; } - if (t == _i77.DataRightsException) { - return _i77.DataRightsException.fromJson(data) as T; + if (t == _i81.AuthenticationFailedException) { + return _i81.AuthenticationFailedException.fromJson(data) as T; } - if (t == _i78.EndpointDisabledException) { - return _i78.EndpointDisabledException.fromJson(data) as T; + if (t == _i82.DataRightsException) { + return _i82.DataRightsException.fromJson(data) as T; } - if (t == _i79.EnrollmentException) { - return _i79.EnrollmentException.fromJson(data) as T; + if (t == _i83.EndpointDisabledException) { + return _i83.EndpointDisabledException.fromJson(data) as T; } - if (t == _i80.MfaEnrollmentRequiredException) { - return _i80.MfaEnrollmentRequiredException.fromJson(data) as T; + if (t == _i84.EnrollmentException) { + return _i84.EnrollmentException.fromJson(data) as T; } - if (t == _i81.MfaRequiredException) { - return _i81.MfaRequiredException.fromJson(data) as T; + if (t == _i85.MfaEnrollmentRequiredException) { + return _i85.MfaEnrollmentRequiredException.fromJson(data) as T; } - if (t == _i82.NoticeDeliveryException) { - return _i82.NoticeDeliveryException.fromJson(data) as T; + if (t == _i86.MfaRequiredException) { + return _i86.MfaRequiredException.fromJson(data) as T; } - if (t == _i83.OtpRequestException) { - return _i83.OtpRequestException.fromJson(data) as T; + if (t == _i87.NoticeDeliveryException) { + return _i87.NoticeDeliveryException.fromJson(data) as T; } - if (t == _i84.SessionExpiredException) { - return _i84.SessionExpiredException.fromJson(data) as T; + if (t == _i88.OtpRequestException) { + return _i88.OtpRequestException.fromJson(data) as T; } - if (t == _i85.MicroArea) { - return _i85.MicroArea.fromJson(data) as T; + if (t == _i89.SessionExpiredException) { + return _i89.SessionExpiredException.fromJson(data) as T; } - if (t == _i86.OtpChallenge) { - return _i86.OtpChallenge.fromJson(data) as T; + if (t == _i90.MicroArea) { + return _i90.MicroArea.fromJson(data) as T; } - if (t == _i87.Patient) { - return _i87.Patient.fromJson(data) as T; + if (t == _i91.OtpChallenge) { + return _i91.OtpChallenge.fromJson(data) as T; } - if (t == _i88.PushToken) { - return _i88.PushToken.fromJson(data) as T; + if (t == _i92.Patient) { + return _i92.Patient.fromJson(data) as T; } - if (t == _i89.ScheduledNotice) { - return _i89.ScheduledNotice.fromJson(data) as T; + if (t == _i93.PushToken) { + return _i93.PushToken.fromJson(data) as T; } - if (t == _i90.StaffAccount) { - return _i90.StaffAccount.fromJson(data) as T; + if (t == _i94.ScheduledNotice) { + return _i94.ScheduledNotice.fromJson(data) as T; } - if (t == _i91.TermsVersion) { - return _i91.TermsVersion.fromJson(data) as T; + if (t == _i95.StaffAccount) { + return _i95.StaffAccount.fromJson(data) as T; } - if (t == _i92.TriageAnswer) { - return _i92.TriageAnswer.fromJson(data) as T; + if (t == _i96.TermsVersion) { + return _i96.TermsVersion.fromJson(data) as T; } - if (t == _i93.TriageSession) { - return _i93.TriageSession.fromJson(data) as T; + if (t == _i97.TriageAnswer) { + return _i97.TriageAnswer.fromJson(data) as T; } - if (t == _i94.Ubs) { - return _i94.Ubs.fromJson(data) as T; + if (t == _i98.TriageSession) { + return _i98.TriageSession.fromJson(data) as T; } - if (t == _i95.User) { - return _i95.User.fromJson(data) as T; + if (t == _i99.Ubs) { + return _i99.Ubs.fromJson(data) as T; } - if (t == _i96.UserCredential) { - return _i96.UserCredential.fromJson(data) as T; + if (t == _i100.User) { + return _i100.User.fromJson(data) as T; } - if (t == _i97.Visit) { - return _i97.Visit.fromJson(data) as T; + if (t == _i101.UserCredential) { + return _i101.UserCredential.fromJson(data) as T; + } + if (t == _i102.Visit) { + return _i102.Visit.fromJson(data) as T; } if (t == _i1.getType<_i2.Acs?>()) { return (data != null ? _i2.Acs.fromJson(data) : null) as T; @@ -573,364 +601,380 @@ class Protocol extends _i1.SerializationManager { return (data != null ? _i11.AdminAcsCreationResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i12.AdminAlert?>()) { - return (data != null ? _i12.AdminAlert.fromJson(data) : null) as T; + if (t == _i1.getType<_i12.AdminAcsDevice?>()) { + return (data != null ? _i12.AdminAcsDevice.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i13.AdminAlert?>()) { + return (data != null ? _i13.AdminAlert.fromJson(data) : null) as T; } - if (t == _i1.getType<_i13.AdminAlertPage?>()) { - return (data != null ? _i13.AdminAlertPage.fromJson(data) : null) as T; + if (t == _i1.getType<_i14.AdminAlertPage?>()) { + return (data != null ? _i14.AdminAlertPage.fromJson(data) : null) as T; } - if (t == _i1.getType<_i14.AdminAlertsPulse?>()) { - return (data != null ? _i14.AdminAlertsPulse.fromJson(data) : null) as T; + if (t == _i1.getType<_i15.AdminAlertsPulse?>()) { + return (data != null ? _i15.AdminAlertsPulse.fromJson(data) : null) as T; } - if (t == _i1.getType<_i15.AdminAuditChainStatus?>()) { - return (data != null ? _i15.AdminAuditChainStatus.fromJson(data) : null) + if (t == _i1.getType<_i16.AdminAuditChainStatus?>()) { + return (data != null ? _i16.AdminAuditChainStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i16.AdminAuditEntry?>()) { - return (data != null ? _i16.AdminAuditEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i17.AdminAuditEntry?>()) { + return (data != null ? _i17.AdminAuditEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i17.AdminAuditExport?>()) { - return (data != null ? _i17.AdminAuditExport.fromJson(data) : null) as T; + if (t == _i1.getType<_i18.AdminAuditExport?>()) { + return (data != null ? _i18.AdminAuditExport.fromJson(data) : null) as T; } - if (t == _i1.getType<_i18.AdminAuditFilter?>()) { - return (data != null ? _i18.AdminAuditFilter.fromJson(data) : null) as T; + if (t == _i1.getType<_i19.AdminAuditFilter?>()) { + return (data != null ? _i19.AdminAuditFilter.fromJson(data) : null) as T; } - if (t == _i1.getType<_i19.AdminAuditPage?>()) { - return (data != null ? _i19.AdminAuditPage.fromJson(data) : null) as T; + if (t == _i1.getType<_i20.AdminAuditPage?>()) { + return (data != null ? _i20.AdminAuditPage.fromJson(data) : null) as T; } - if (t == _i1.getType<_i20.AdminDataSubjectRequest?>()) { - return (data != null ? _i20.AdminDataSubjectRequest.fromJson(data) : null) + if (t == _i1.getType<_i21.AdminDataSubjectRequest?>()) { + return (data != null ? _i21.AdminDataSubjectRequest.fromJson(data) : null) as T; } - if (t == _i1.getType<_i21.AdminDataSubjectRequestDetail?>()) { + if (t == _i1.getType<_i22.AdminDataSubjectRequestDetail?>()) { return (data != null - ? _i21.AdminDataSubjectRequestDetail.fromJson(data) + ? _i22.AdminDataSubjectRequestDetail.fromJson(data) : null) as T; } - if (t == _i1.getType<_i22.AdminDataSubjectRequestPage?>()) { + if (t == _i1.getType<_i23.AdminDataSubjectRequestPage?>()) { return (data != null - ? _i22.AdminDataSubjectRequestPage.fromJson(data) + ? _i23.AdminDataSubjectRequestPage.fromJson(data) : null) as T; } - if (t == _i1.getType<_i23.AdminEscalationResult?>()) { - return (data != null ? _i23.AdminEscalationResult.fromJson(data) : null) + if (t == _i1.getType<_i24.AdminDeviceWipeResult?>()) { + return (data != null ? _i24.AdminDeviceWipeResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i24.AdminIndicators?>()) { - return (data != null ? _i24.AdminIndicators.fromJson(data) : null) as T; + if (t == _i1.getType<_i25.AdminEscalationResult?>()) { + return (data != null ? _i25.AdminEscalationResult.fromJson(data) : null) + as T; } - if (t == _i1.getType<_i25.AdminMicroArea?>()) { - return (data != null ? _i25.AdminMicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i26.AdminIndicators?>()) { + return (data != null ? _i26.AdminIndicators.fromJson(data) : null) as T; } - if (t == _i1.getType<_i26.AdminPasswordResetResult?>()) { + if (t == _i1.getType<_i27.AdminMicroArea?>()) { + return (data != null ? _i27.AdminMicroArea.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i28.AdminPasswordResetResult?>()) { return (data != null - ? _i26.AdminPasswordResetResult.fromJson(data) + ? _i28.AdminPasswordResetResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i27.AdminStaff?>()) { - return (data != null ? _i27.AdminStaff.fromJson(data) : null) as T; + if (t == _i1.getType<_i29.AdminStaff?>()) { + return (data != null ? _i29.AdminStaff.fromJson(data) : null) as T; } - if (t == _i1.getType<_i28.AdminStaffMfaResetResult?>()) { + if (t == _i1.getType<_i30.AdminStaffMfaResetResult?>()) { return (data != null - ? _i28.AdminStaffMfaResetResult.fromJson(data) + ? _i30.AdminStaffMfaResetResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i29.AlertAckResult?>()) { - return (data != null ? _i29.AlertAckResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i31.AlertAckResult?>()) { + return (data != null ? _i31.AlertAckResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i30.AlertResolveResult?>()) { - return (data != null ? _i30.AlertResolveResult.fromJson(data) : null) + if (t == _i1.getType<_i32.AlertResolveResult?>()) { + return (data != null ? _i32.AlertResolveResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i31.AlertStatusResult?>()) { - return (data != null ? _i31.AlertStatusResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i33.AlertStatusResult?>()) { + return (data != null ? _i33.AlertStatusResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i32.DevelopmentLoginResult?>()) { - return (data != null ? _i32.DevelopmentLoginResult.fromJson(data) : null) + if (t == _i1.getType<_i34.DevelopmentLoginResult?>()) { + return (data != null ? _i34.DevelopmentLoginResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i33.EnrollmentResult?>()) { - return (data != null ? _i33.EnrollmentResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i35.EnrollmentResult?>()) { + return (data != null ? _i35.EnrollmentResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i34.EnrollmentTokenResult?>()) { - return (data != null ? _i34.EnrollmentTokenResult.fromJson(data) : null) + if (t == _i1.getType<_i36.EnrollmentTokenResult?>()) { + return (data != null ? _i36.EnrollmentTokenResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i35.MicroAreaPatient?>()) { - return (data != null ? _i35.MicroAreaPatient.fromJson(data) : null) as T; + if (t == _i1.getType<_i37.MicroAreaPatient?>()) { + return (data != null ? _i37.MicroAreaPatient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i36.NoticeHistoryEntry?>()) { - return (data != null ? _i36.NoticeHistoryEntry.fromJson(data) : null) + if (t == _i1.getType<_i38.NoticeHistoryEntry?>()) { + return (data != null ? _i38.NoticeHistoryEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i37.NoticeHistoryPage?>()) { - return (data != null ? _i37.NoticeHistoryPage.fromJson(data) : null) as T; + if (t == _i1.getType<_i39.NoticeHistoryPage?>()) { + return (data != null ? _i39.NoticeHistoryPage.fromJson(data) : null) as T; } - if (t == _i1.getType<_i38.NoticeSendResult?>()) { - return (data != null ? _i38.NoticeSendResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i40.NoticeSendResult?>()) { + return (data != null ? _i40.NoticeSendResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i39.PatientConsentRecord?>()) { - return (data != null ? _i39.PatientConsentRecord.fromJson(data) : null) + if (t == _i1.getType<_i41.PatientConsentRecord?>()) { + return (data != null ? _i41.PatientConsentRecord.fromJson(data) : null) as T; } - if (t == _i1.getType<_i40.PatientDataOverview?>()) { - return (data != null ? _i40.PatientDataOverview.fromJson(data) : null) + if (t == _i1.getType<_i42.PatientDataOverview?>()) { + return (data != null ? _i42.PatientDataOverview.fromJson(data) : null) as T; } - if (t == _i1.getType<_i41.PatientDataSubjectRequestRecord?>()) { + if (t == _i1.getType<_i43.PatientDataSubjectRequestRecord?>()) { return (data != null - ? _i41.PatientDataSubjectRequestRecord.fromJson(data) + ? _i43.PatientDataSubjectRequestRecord.fromJson(data) : null) as T; } - if (t == _i1.getType<_i42.PatientRiskEvent?>()) { - return (data != null ? _i42.PatientRiskEvent.fromJson(data) : null) as T; + if (t == _i1.getType<_i44.PatientRiskEvent?>()) { + return (data != null ? _i44.PatientRiskEvent.fromJson(data) : null) as T; } - if (t == _i1.getType<_i43.RedAlertResult?>()) { - return (data != null ? _i43.RedAlertResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i45.RedAlertResult?>()) { + return (data != null ? _i45.RedAlertResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i44.ServiceHealth?>()) { - return (data != null ? _i44.ServiceHealth.fromJson(data) : null) as T; + if (t == _i1.getType<_i46.ServiceHealth?>()) { + return (data != null ? _i46.ServiceHealth.fromJson(data) : null) as T; } - if (t == _i1.getType<_i45.TermsChangeNotice?>()) { - return (data != null ? _i45.TermsChangeNotice.fromJson(data) : null) as T; + if (t == _i1.getType<_i47.TermsChangeNotice?>()) { + return (data != null ? _i47.TermsChangeNotice.fromJson(data) : null) as T; } - if (t == _i1.getType<_i46.TermsVersionEntry?>()) { - return (data != null ? _i46.TermsVersionEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i48.TermsVersionEntry?>()) { + return (data != null ? _i48.TermsVersionEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i47.TotpEnrollmentStart?>()) { - return (data != null ? _i47.TotpEnrollmentStart.fromJson(data) : null) + if (t == _i1.getType<_i49.TotpEnrollmentStart?>()) { + return (data != null ? _i49.TotpEnrollmentStart.fromJson(data) : null) as T; } - if (t == _i1.getType<_i48.TriageResult?>()) { - return (data != null ? _i48.TriageResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i50.TriageResult?>()) { + return (data != null ? _i50.TriageResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i49.UbsContact?>()) { - return (data != null ? _i49.UbsContact.fromJson(data) : null) as T; + if (t == _i1.getType<_i51.UbsContact?>()) { + return (data != null ? _i51.UbsContact.fromJson(data) : null) as T; } - if (t == _i1.getType<_i50.VisitSyncEntry?>()) { - return (data != null ? _i50.VisitSyncEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i52.VisitSyncEntry?>()) { + return (data != null ? _i52.VisitSyncEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i51.VisitSyncResult?>()) { - return (data != null ? _i51.VisitSyncResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i53.VisitSyncResult?>()) { + return (data != null ? _i53.VisitSyncResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i52.AuditChainCheckpoint?>()) { - return (data != null ? _i52.AuditChainCheckpoint.fromJson(data) : null) + if (t == _i1.getType<_i54.AuditChainCheckpoint?>()) { + return (data != null ? _i54.AuditChainCheckpoint.fromJson(data) : null) as T; } - if (t == _i1.getType<_i53.AuditExport?>()) { - return (data != null ? _i53.AuditExport.fromJson(data) : null) as T; + if (t == _i1.getType<_i55.AuditExport?>()) { + return (data != null ? _i55.AuditExport.fromJson(data) : null) as T; } - if (t == _i1.getType<_i54.AuditLog?>()) { - return (data != null ? _i54.AuditLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i56.AuditLog?>()) { + return (data != null ? _i56.AuditLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i55.ConsentLog?>()) { - return (data != null ? _i55.ConsentLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i57.ConsentLog?>()) { + return (data != null ? _i57.ConsentLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i56.DataSubjectRequest?>()) { - return (data != null ? _i56.DataSubjectRequest.fromJson(data) : null) + if (t == _i1.getType<_i58.DataSubjectRequest?>()) { + return (data != null ? _i58.DataSubjectRequest.fromJson(data) : null) as T; } - if (t == _i1.getType<_i57.EnrollmentToken?>()) { - return (data != null ? _i57.EnrollmentToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i59.DeviceWipeOrder?>()) { + return (data != null ? _i59.DeviceWipeOrder.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i60.EnrollmentToken?>()) { + return (data != null ? _i60.EnrollmentToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i58.AlertStatus?>()) { - return (data != null ? _i58.AlertStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i61.AlertStatus?>()) { + return (data != null ? _i61.AlertStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i59.ArrivalMethod?>()) { - return (data != null ? _i59.ArrivalMethod.fromJson(data) : null) as T; + if (t == _i1.getType<_i62.ArrivalMethod?>()) { + return (data != null ? _i62.ArrivalMethod.fromJson(data) : null) as T; } - if (t == _i1.getType<_i60.AuditChainReason?>()) { - return (data != null ? _i60.AuditChainReason.fromJson(data) : null) as T; + if (t == _i1.getType<_i63.AuditChainReason?>()) { + return (data != null ? _i63.AuditChainReason.fromJson(data) : null) as T; } - if (t == _i1.getType<_i61.AuditIntegrityState?>()) { - return (data != null ? _i61.AuditIntegrityState.fromJson(data) : null) + if (t == _i1.getType<_i64.AuditIntegrityState?>()) { + return (data != null ? _i64.AuditIntegrityState.fromJson(data) : null) as T; } - if (t == _i1.getType<_i62.AuditRowSeal?>()) { - return (data != null ? _i62.AuditRowSeal.fromJson(data) : null) as T; + if (t == _i1.getType<_i65.AuditRowSeal?>()) { + return (data != null ? _i65.AuditRowSeal.fromJson(data) : null) as T; } - if (t == _i1.getType<_i63.ConsentPurpose?>()) { - return (data != null ? _i63.ConsentPurpose.fromJson(data) : null) as T; + if (t == _i1.getType<_i66.ConsentPurpose?>()) { + return (data != null ? _i66.ConsentPurpose.fromJson(data) : null) as T; } - if (t == _i1.getType<_i64.DataSubjectRequestStatus?>()) { + if (t == _i1.getType<_i67.DataSubjectRequestStatus?>()) { return (data != null - ? _i64.DataSubjectRequestStatus.fromJson(data) + ? _i67.DataSubjectRequestStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i65.DataSubjectRequestType?>()) { - return (data != null ? _i65.DataSubjectRequestType.fromJson(data) : null) + if (t == _i1.getType<_i68.DataSubjectRequestType?>()) { + return (data != null ? _i68.DataSubjectRequestType.fromJson(data) : null) as T; } - if (t == _i1.getType<_i66.EscalationReason?>()) { - return (data != null ? _i66.EscalationReason.fromJson(data) : null) as T; + if (t == _i1.getType<_i69.DeviceWipeReason?>()) { + return (data != null ? _i69.DeviceWipeReason.fromJson(data) : null) as T; } - if (t == _i1.getType<_i67.RiskLevel?>()) { - return (data != null ? _i67.RiskLevel.fromJson(data) : null) as T; + if (t == _i1.getType<_i70.DeviceWipeState?>()) { + return (data != null ? _i70.DeviceWipeState.fromJson(data) : null) as T; } - if (t == _i1.getType<_i68.ScheduledNoticeStatus?>()) { - return (data != null ? _i68.ScheduledNoticeStatus.fromJson(data) : null) + if (t == _i1.getType<_i71.EscalationReason?>()) { + return (data != null ? _i71.EscalationReason.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i72.RiskLevel?>()) { + return (data != null ? _i72.RiskLevel.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i73.ScheduledNoticeStatus?>()) { + return (data != null ? _i73.ScheduledNoticeStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i69.SyncStatus?>()) { - return (data != null ? _i69.SyncStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i74.SyncStatus?>()) { + return (data != null ? _i74.SyncStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i70.UserRole?>()) { - return (data != null ? _i70.UserRole.fromJson(data) : null) as T; + if (t == _i1.getType<_i75.UserRole?>()) { + return (data != null ? _i75.UserRole.fromJson(data) : null) as T; } - if (t == _i1.getType<_i71.VisitAuthorship?>()) { - return (data != null ? _i71.VisitAuthorship.fromJson(data) : null) as T; + if (t == _i1.getType<_i76.VisitAuthorship?>()) { + return (data != null ? _i76.VisitAuthorship.fromJson(data) : null) as T; } - if (t == _i1.getType<_i72.AdminInvalidRequestException?>()) { + if (t == _i1.getType<_i77.AdminInvalidRequestException?>()) { return (data != null - ? _i72.AdminInvalidRequestException.fromJson(data) + ? _i77.AdminInvalidRequestException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i73.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i78.AlertDispatchUnavailableException?>()) { return (data != null - ? _i73.AlertDispatchUnavailableException.fromJson(data) + ? _i78.AlertDispatchUnavailableException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i74.AlertPermissionException?>()) { + if (t == _i1.getType<_i79.AlertPermissionException?>()) { return (data != null - ? _i74.AlertPermissionException.fromJson(data) + ? _i79.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i75.AlertValidationException?>()) { + if (t == _i1.getType<_i80.AlertValidationException?>()) { return (data != null - ? _i75.AlertValidationException.fromJson(data) + ? _i80.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i76.AuthenticationFailedException?>()) { + if (t == _i1.getType<_i81.AuthenticationFailedException?>()) { return (data != null - ? _i76.AuthenticationFailedException.fromJson(data) + ? _i81.AuthenticationFailedException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i77.DataRightsException?>()) { - return (data != null ? _i77.DataRightsException.fromJson(data) : null) + if (t == _i1.getType<_i82.DataRightsException?>()) { + return (data != null ? _i82.DataRightsException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i78.EndpointDisabledException?>()) { + if (t == _i1.getType<_i83.EndpointDisabledException?>()) { return (data != null - ? _i78.EndpointDisabledException.fromJson(data) + ? _i83.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i79.EnrollmentException?>()) { - return (data != null ? _i79.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i84.EnrollmentException?>()) { + return (data != null ? _i84.EnrollmentException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i80.MfaEnrollmentRequiredException?>()) { + if (t == _i1.getType<_i85.MfaEnrollmentRequiredException?>()) { return (data != null - ? _i80.MfaEnrollmentRequiredException.fromJson(data) + ? _i85.MfaEnrollmentRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i81.MfaRequiredException?>()) { - return (data != null ? _i81.MfaRequiredException.fromJson(data) : null) + if (t == _i1.getType<_i86.MfaRequiredException?>()) { + return (data != null ? _i86.MfaRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i82.NoticeDeliveryException?>()) { - return (data != null ? _i82.NoticeDeliveryException.fromJson(data) : null) + if (t == _i1.getType<_i87.NoticeDeliveryException?>()) { + return (data != null ? _i87.NoticeDeliveryException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i83.OtpRequestException?>()) { - return (data != null ? _i83.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i88.OtpRequestException?>()) { + return (data != null ? _i88.OtpRequestException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i84.SessionExpiredException?>()) { - return (data != null ? _i84.SessionExpiredException.fromJson(data) : null) + if (t == _i1.getType<_i89.SessionExpiredException?>()) { + return (data != null ? _i89.SessionExpiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i85.MicroArea?>()) { - return (data != null ? _i85.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i90.MicroArea?>()) { + return (data != null ? _i90.MicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i86.OtpChallenge?>()) { - return (data != null ? _i86.OtpChallenge.fromJson(data) : null) as T; + if (t == _i1.getType<_i91.OtpChallenge?>()) { + return (data != null ? _i91.OtpChallenge.fromJson(data) : null) as T; } - if (t == _i1.getType<_i87.Patient?>()) { - return (data != null ? _i87.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i92.Patient?>()) { + return (data != null ? _i92.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i88.PushToken?>()) { - return (data != null ? _i88.PushToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i93.PushToken?>()) { + return (data != null ? _i93.PushToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i89.ScheduledNotice?>()) { - return (data != null ? _i89.ScheduledNotice.fromJson(data) : null) as T; + if (t == _i1.getType<_i94.ScheduledNotice?>()) { + return (data != null ? _i94.ScheduledNotice.fromJson(data) : null) as T; } - if (t == _i1.getType<_i90.StaffAccount?>()) { - return (data != null ? _i90.StaffAccount.fromJson(data) : null) as T; + if (t == _i1.getType<_i95.StaffAccount?>()) { + return (data != null ? _i95.StaffAccount.fromJson(data) : null) as T; } - if (t == _i1.getType<_i91.TermsVersion?>()) { - return (data != null ? _i91.TermsVersion.fromJson(data) : null) as T; + if (t == _i1.getType<_i96.TermsVersion?>()) { + return (data != null ? _i96.TermsVersion.fromJson(data) : null) as T; } - if (t == _i1.getType<_i92.TriageAnswer?>()) { - return (data != null ? _i92.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i97.TriageAnswer?>()) { + return (data != null ? _i97.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i93.TriageSession?>()) { - return (data != null ? _i93.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i98.TriageSession?>()) { + return (data != null ? _i98.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i94.Ubs?>()) { - return (data != null ? _i94.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i99.Ubs?>()) { + return (data != null ? _i99.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i95.User?>()) { - return (data != null ? _i95.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i100.User?>()) { + return (data != null ? _i100.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i96.UserCredential?>()) { - return (data != null ? _i96.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i101.UserCredential?>()) { + return (data != null ? _i101.UserCredential.fromJson(data) : null) as T; } - if (t == _i1.getType<_i97.Visit?>()) { - return (data != null ? _i97.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i102.Visit?>()) { + return (data != null ? _i102.Visit.fromJson(data) : null) as T; } - if (t == List<_i12.AdminAlert>) { - return (data as List).map((e) => deserialize<_i12.AdminAlert>(e)).toList() + if (t == List<_i13.AdminAlert>) { + return (data as List).map((e) => deserialize<_i13.AdminAlert>(e)).toList() as T; } - if (t == List<_i16.AdminAuditEntry>) { + if (t == List<_i17.AdminAuditEntry>) { return (data as List) - .map((e) => deserialize<_i16.AdminAuditEntry>(e)) + .map((e) => deserialize<_i17.AdminAuditEntry>(e)) .toList() as T; } - if (t == List<_i20.AdminDataSubjectRequest>) { + if (t == List<_i21.AdminDataSubjectRequest>) { return (data as List) - .map((e) => deserialize<_i20.AdminDataSubjectRequest>(e)) + .map((e) => deserialize<_i21.AdminDataSubjectRequest>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i36.NoticeHistoryEntry>) { + if (t == List<_i38.NoticeHistoryEntry>) { return (data as List) - .map((e) => deserialize<_i36.NoticeHistoryEntry>(e)) + .map((e) => deserialize<_i38.NoticeHistoryEntry>(e)) .toList() as T; } - if (t == List<_i39.PatientConsentRecord>) { + if (t == List<_i41.PatientConsentRecord>) { return (data as List) - .map((e) => deserialize<_i39.PatientConsentRecord>(e)) + .map((e) => deserialize<_i41.PatientConsentRecord>(e)) .toList() as T; } - if (t == List<_i42.PatientRiskEvent>) { + if (t == List<_i44.PatientRiskEvent>) { return (data as List) - .map((e) => deserialize<_i42.PatientRiskEvent>(e)) + .map((e) => deserialize<_i44.PatientRiskEvent>(e)) .toList() as T; } - if (t == List<_i41.PatientDataSubjectRequestRecord>) { + if (t == List<_i43.PatientDataSubjectRequestRecord>) { return (data as List) - .map((e) => deserialize<_i41.PatientDataSubjectRequestRecord>(e)) + .map((e) => deserialize<_i43.PatientDataSubjectRequestRecord>(e)) .toList() as T; } @@ -940,52 +984,58 @@ class Protocol extends _i1.SerializationManager { ) as T; } - if (t == List<_i98.AdminMicroArea>) { + if (t == List<_i103.AdminMicroArea>) { return (data as List) - .map((e) => deserialize<_i98.AdminMicroArea>(e)) + .map((e) => deserialize<_i103.AdminMicroArea>(e)) .toList() as T; } - if (t == List<_i99.AdminAcs>) { - return (data as List).map((e) => deserialize<_i99.AdminAcs>(e)).toList() + if (t == List<_i104.AdminAcs>) { + return (data as List).map((e) => deserialize<_i104.AdminAcs>(e)).toList() + as T; + } + if (t == List<_i105.AdminStaff>) { + return (data as List) + .map((e) => deserialize<_i105.AdminStaff>(e)) + .toList() as T; } - if (t == List<_i100.AdminStaff>) { + if (t == List<_i106.AdminAcsDevice>) { return (data as List) - .map((e) => deserialize<_i100.AdminStaff>(e)) + .map((e) => deserialize<_i106.AdminAcsDevice>(e)) .toList() as T; } - if (t == List<_i101.TermsVersionEntry>) { + if (t == List<_i107.TermsVersionEntry>) { return (data as List) - .map((e) => deserialize<_i101.TermsVersionEntry>(e)) + .map((e) => deserialize<_i107.TermsVersionEntry>(e)) .toList() as T; } - if (t == List<_i102.AcsOpenAlert>) { + if (t == List<_i108.AcsOpenAlert>) { return (data as List) - .map((e) => deserialize<_i102.AcsOpenAlert>(e)) + .map((e) => deserialize<_i108.AcsOpenAlert>(e)) .toList() as T; } - if (t == List<_i103.MicroAreaPatient>) { + if (t == List<_i109.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i103.MicroAreaPatient>(e)) + .map((e) => deserialize<_i109.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i104.VisitSyncResult>) { + if (t == List<_i110.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i104.VisitSyncResult>(e)) + .map((e) => deserialize<_i110.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i105.VisitSyncEntry>) { + if (t == List<_i111.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i105.VisitSyncEntry>(e)) + .map((e) => deserialize<_i111.VisitSyncEntry>(e)) .toList() as T; } @@ -1004,93 +1054,98 @@ class Protocol extends _i1.SerializationManager { _i9.AcsOpenAlert => 'AcsOpenAlert', _i10.AdminAcs => 'AdminAcs', _i11.AdminAcsCreationResult => 'AdminAcsCreationResult', - _i12.AdminAlert => 'AdminAlert', - _i13.AdminAlertPage => 'AdminAlertPage', - _i14.AdminAlertsPulse => 'AdminAlertsPulse', - _i15.AdminAuditChainStatus => 'AdminAuditChainStatus', - _i16.AdminAuditEntry => 'AdminAuditEntry', - _i17.AdminAuditExport => 'AdminAuditExport', - _i18.AdminAuditFilter => 'AdminAuditFilter', - _i19.AdminAuditPage => 'AdminAuditPage', - _i20.AdminDataSubjectRequest => 'AdminDataSubjectRequest', - _i21.AdminDataSubjectRequestDetail => 'AdminDataSubjectRequestDetail', - _i22.AdminDataSubjectRequestPage => 'AdminDataSubjectRequestPage', - _i23.AdminEscalationResult => 'AdminEscalationResult', - _i24.AdminIndicators => 'AdminIndicators', - _i25.AdminMicroArea => 'AdminMicroArea', - _i26.AdminPasswordResetResult => 'AdminPasswordResetResult', - _i27.AdminStaff => 'AdminStaff', - _i28.AdminStaffMfaResetResult => 'AdminStaffMfaResetResult', - _i29.AlertAckResult => 'AlertAckResult', - _i30.AlertResolveResult => 'AlertResolveResult', - _i31.AlertStatusResult => 'AlertStatusResult', - _i32.DevelopmentLoginResult => 'DevelopmentLoginResult', - _i33.EnrollmentResult => 'EnrollmentResult', - _i34.EnrollmentTokenResult => 'EnrollmentTokenResult', - _i35.MicroAreaPatient => 'MicroAreaPatient', - _i36.NoticeHistoryEntry => 'NoticeHistoryEntry', - _i37.NoticeHistoryPage => 'NoticeHistoryPage', - _i38.NoticeSendResult => 'NoticeSendResult', - _i39.PatientConsentRecord => 'PatientConsentRecord', - _i40.PatientDataOverview => 'PatientDataOverview', - _i41.PatientDataSubjectRequestRecord => 'PatientDataSubjectRequestRecord', - _i42.PatientRiskEvent => 'PatientRiskEvent', - _i43.RedAlertResult => 'RedAlertResult', - _i44.ServiceHealth => 'ServiceHealth', - _i45.TermsChangeNotice => 'TermsChangeNotice', - _i46.TermsVersionEntry => 'TermsVersionEntry', - _i47.TotpEnrollmentStart => 'TotpEnrollmentStart', - _i48.TriageResult => 'TriageResult', - _i49.UbsContact => 'UbsContact', - _i50.VisitSyncEntry => 'VisitSyncEntry', - _i51.VisitSyncResult => 'VisitSyncResult', - _i52.AuditChainCheckpoint => 'AuditChainCheckpoint', - _i53.AuditExport => 'AuditExport', - _i54.AuditLog => 'AuditLog', - _i55.ConsentLog => 'ConsentLog', - _i56.DataSubjectRequest => 'DataSubjectRequest', - _i57.EnrollmentToken => 'EnrollmentToken', - _i58.AlertStatus => 'AlertStatus', - _i59.ArrivalMethod => 'ArrivalMethod', - _i60.AuditChainReason => 'AuditChainReason', - _i61.AuditIntegrityState => 'AuditIntegrityState', - _i62.AuditRowSeal => 'AuditRowSeal', - _i63.ConsentPurpose => 'ConsentPurpose', - _i64.DataSubjectRequestStatus => 'DataSubjectRequestStatus', - _i65.DataSubjectRequestType => 'DataSubjectRequestType', - _i66.EscalationReason => 'EscalationReason', - _i67.RiskLevel => 'RiskLevel', - _i68.ScheduledNoticeStatus => 'ScheduledNoticeStatus', - _i69.SyncStatus => 'SyncStatus', - _i70.UserRole => 'UserRole', - _i71.VisitAuthorship => 'VisitAuthorship', - _i72.AdminInvalidRequestException => 'AdminInvalidRequestException', - _i73.AlertDispatchUnavailableException => + _i12.AdminAcsDevice => 'AdminAcsDevice', + _i13.AdminAlert => 'AdminAlert', + _i14.AdminAlertPage => 'AdminAlertPage', + _i15.AdminAlertsPulse => 'AdminAlertsPulse', + _i16.AdminAuditChainStatus => 'AdminAuditChainStatus', + _i17.AdminAuditEntry => 'AdminAuditEntry', + _i18.AdminAuditExport => 'AdminAuditExport', + _i19.AdminAuditFilter => 'AdminAuditFilter', + _i20.AdminAuditPage => 'AdminAuditPage', + _i21.AdminDataSubjectRequest => 'AdminDataSubjectRequest', + _i22.AdminDataSubjectRequestDetail => 'AdminDataSubjectRequestDetail', + _i23.AdminDataSubjectRequestPage => 'AdminDataSubjectRequestPage', + _i24.AdminDeviceWipeResult => 'AdminDeviceWipeResult', + _i25.AdminEscalationResult => 'AdminEscalationResult', + _i26.AdminIndicators => 'AdminIndicators', + _i27.AdminMicroArea => 'AdminMicroArea', + _i28.AdminPasswordResetResult => 'AdminPasswordResetResult', + _i29.AdminStaff => 'AdminStaff', + _i30.AdminStaffMfaResetResult => 'AdminStaffMfaResetResult', + _i31.AlertAckResult => 'AlertAckResult', + _i32.AlertResolveResult => 'AlertResolveResult', + _i33.AlertStatusResult => 'AlertStatusResult', + _i34.DevelopmentLoginResult => 'DevelopmentLoginResult', + _i35.EnrollmentResult => 'EnrollmentResult', + _i36.EnrollmentTokenResult => 'EnrollmentTokenResult', + _i37.MicroAreaPatient => 'MicroAreaPatient', + _i38.NoticeHistoryEntry => 'NoticeHistoryEntry', + _i39.NoticeHistoryPage => 'NoticeHistoryPage', + _i40.NoticeSendResult => 'NoticeSendResult', + _i41.PatientConsentRecord => 'PatientConsentRecord', + _i42.PatientDataOverview => 'PatientDataOverview', + _i43.PatientDataSubjectRequestRecord => 'PatientDataSubjectRequestRecord', + _i44.PatientRiskEvent => 'PatientRiskEvent', + _i45.RedAlertResult => 'RedAlertResult', + _i46.ServiceHealth => 'ServiceHealth', + _i47.TermsChangeNotice => 'TermsChangeNotice', + _i48.TermsVersionEntry => 'TermsVersionEntry', + _i49.TotpEnrollmentStart => 'TotpEnrollmentStart', + _i50.TriageResult => 'TriageResult', + _i51.UbsContact => 'UbsContact', + _i52.VisitSyncEntry => 'VisitSyncEntry', + _i53.VisitSyncResult => 'VisitSyncResult', + _i54.AuditChainCheckpoint => 'AuditChainCheckpoint', + _i55.AuditExport => 'AuditExport', + _i56.AuditLog => 'AuditLog', + _i57.ConsentLog => 'ConsentLog', + _i58.DataSubjectRequest => 'DataSubjectRequest', + _i59.DeviceWipeOrder => 'DeviceWipeOrder', + _i60.EnrollmentToken => 'EnrollmentToken', + _i61.AlertStatus => 'AlertStatus', + _i62.ArrivalMethod => 'ArrivalMethod', + _i63.AuditChainReason => 'AuditChainReason', + _i64.AuditIntegrityState => 'AuditIntegrityState', + _i65.AuditRowSeal => 'AuditRowSeal', + _i66.ConsentPurpose => 'ConsentPurpose', + _i67.DataSubjectRequestStatus => 'DataSubjectRequestStatus', + _i68.DataSubjectRequestType => 'DataSubjectRequestType', + _i69.DeviceWipeReason => 'DeviceWipeReason', + _i70.DeviceWipeState => 'DeviceWipeState', + _i71.EscalationReason => 'EscalationReason', + _i72.RiskLevel => 'RiskLevel', + _i73.ScheduledNoticeStatus => 'ScheduledNoticeStatus', + _i74.SyncStatus => 'SyncStatus', + _i75.UserRole => 'UserRole', + _i76.VisitAuthorship => 'VisitAuthorship', + _i77.AdminInvalidRequestException => 'AdminInvalidRequestException', + _i78.AlertDispatchUnavailableException => 'AlertDispatchUnavailableException', - _i74.AlertPermissionException => 'AlertPermissionException', - _i75.AlertValidationException => 'AlertValidationException', - _i76.AuthenticationFailedException => 'AuthenticationFailedException', - _i77.DataRightsException => 'DataRightsException', - _i78.EndpointDisabledException => 'EndpointDisabledException', - _i79.EnrollmentException => 'EnrollmentException', - _i80.MfaEnrollmentRequiredException => 'MfaEnrollmentRequiredException', - _i81.MfaRequiredException => 'MfaRequiredException', - _i82.NoticeDeliveryException => 'NoticeDeliveryException', - _i83.OtpRequestException => 'OtpRequestException', - _i84.SessionExpiredException => 'SessionExpiredException', - _i85.MicroArea => 'MicroArea', - _i86.OtpChallenge => 'OtpChallenge', - _i87.Patient => 'Patient', - _i88.PushToken => 'PushToken', - _i89.ScheduledNotice => 'ScheduledNotice', - _i90.StaffAccount => 'StaffAccount', - _i91.TermsVersion => 'TermsVersion', - _i92.TriageAnswer => 'TriageAnswer', - _i93.TriageSession => 'TriageSession', - _i94.Ubs => 'Ubs', - _i95.User => 'User', - _i96.UserCredential => 'UserCredential', - _i97.Visit => 'Visit', + _i79.AlertPermissionException => 'AlertPermissionException', + _i80.AlertValidationException => 'AlertValidationException', + _i81.AuthenticationFailedException => 'AuthenticationFailedException', + _i82.DataRightsException => 'DataRightsException', + _i83.EndpointDisabledException => 'EndpointDisabledException', + _i84.EnrollmentException => 'EnrollmentException', + _i85.MfaEnrollmentRequiredException => 'MfaEnrollmentRequiredException', + _i86.MfaRequiredException => 'MfaRequiredException', + _i87.NoticeDeliveryException => 'NoticeDeliveryException', + _i88.OtpRequestException => 'OtpRequestException', + _i89.SessionExpiredException => 'SessionExpiredException', + _i90.MicroArea => 'MicroArea', + _i91.OtpChallenge => 'OtpChallenge', + _i92.Patient => 'Patient', + _i93.PushToken => 'PushToken', + _i94.ScheduledNotice => 'ScheduledNotice', + _i95.StaffAccount => 'StaffAccount', + _i96.TermsVersion => 'TermsVersion', + _i97.TriageAnswer => 'TriageAnswer', + _i98.TriageSession => 'TriageSession', + _i99.Ubs => 'Ubs', + _i100.User => 'User', + _i101.UserCredential => 'UserCredential', + _i102.Visit => 'Visit', _ => null, }; } @@ -1125,177 +1180,187 @@ class Protocol extends _i1.SerializationManager { return 'AdminAcs'; case _i11.AdminAcsCreationResult(): return 'AdminAcsCreationResult'; - case _i12.AdminAlert(): + case _i12.AdminAcsDevice(): + return 'AdminAcsDevice'; + case _i13.AdminAlert(): return 'AdminAlert'; - case _i13.AdminAlertPage(): + case _i14.AdminAlertPage(): return 'AdminAlertPage'; - case _i14.AdminAlertsPulse(): + case _i15.AdminAlertsPulse(): return 'AdminAlertsPulse'; - case _i15.AdminAuditChainStatus(): + case _i16.AdminAuditChainStatus(): return 'AdminAuditChainStatus'; - case _i16.AdminAuditEntry(): + case _i17.AdminAuditEntry(): return 'AdminAuditEntry'; - case _i17.AdminAuditExport(): + case _i18.AdminAuditExport(): return 'AdminAuditExport'; - case _i18.AdminAuditFilter(): + case _i19.AdminAuditFilter(): return 'AdminAuditFilter'; - case _i19.AdminAuditPage(): + case _i20.AdminAuditPage(): return 'AdminAuditPage'; - case _i20.AdminDataSubjectRequest(): + case _i21.AdminDataSubjectRequest(): return 'AdminDataSubjectRequest'; - case _i21.AdminDataSubjectRequestDetail(): + case _i22.AdminDataSubjectRequestDetail(): return 'AdminDataSubjectRequestDetail'; - case _i22.AdminDataSubjectRequestPage(): + case _i23.AdminDataSubjectRequestPage(): return 'AdminDataSubjectRequestPage'; - case _i23.AdminEscalationResult(): + case _i24.AdminDeviceWipeResult(): + return 'AdminDeviceWipeResult'; + case _i25.AdminEscalationResult(): return 'AdminEscalationResult'; - case _i24.AdminIndicators(): + case _i26.AdminIndicators(): return 'AdminIndicators'; - case _i25.AdminMicroArea(): + case _i27.AdminMicroArea(): return 'AdminMicroArea'; - case _i26.AdminPasswordResetResult(): + case _i28.AdminPasswordResetResult(): return 'AdminPasswordResetResult'; - case _i27.AdminStaff(): + case _i29.AdminStaff(): return 'AdminStaff'; - case _i28.AdminStaffMfaResetResult(): + case _i30.AdminStaffMfaResetResult(): return 'AdminStaffMfaResetResult'; - case _i29.AlertAckResult(): + case _i31.AlertAckResult(): return 'AlertAckResult'; - case _i30.AlertResolveResult(): + case _i32.AlertResolveResult(): return 'AlertResolveResult'; - case _i31.AlertStatusResult(): + case _i33.AlertStatusResult(): return 'AlertStatusResult'; - case _i32.DevelopmentLoginResult(): + case _i34.DevelopmentLoginResult(): return 'DevelopmentLoginResult'; - case _i33.EnrollmentResult(): + case _i35.EnrollmentResult(): return 'EnrollmentResult'; - case _i34.EnrollmentTokenResult(): + case _i36.EnrollmentTokenResult(): return 'EnrollmentTokenResult'; - case _i35.MicroAreaPatient(): + case _i37.MicroAreaPatient(): return 'MicroAreaPatient'; - case _i36.NoticeHistoryEntry(): + case _i38.NoticeHistoryEntry(): return 'NoticeHistoryEntry'; - case _i37.NoticeHistoryPage(): + case _i39.NoticeHistoryPage(): return 'NoticeHistoryPage'; - case _i38.NoticeSendResult(): + case _i40.NoticeSendResult(): return 'NoticeSendResult'; - case _i39.PatientConsentRecord(): + case _i41.PatientConsentRecord(): return 'PatientConsentRecord'; - case _i40.PatientDataOverview(): + case _i42.PatientDataOverview(): return 'PatientDataOverview'; - case _i41.PatientDataSubjectRequestRecord(): + case _i43.PatientDataSubjectRequestRecord(): return 'PatientDataSubjectRequestRecord'; - case _i42.PatientRiskEvent(): + case _i44.PatientRiskEvent(): return 'PatientRiskEvent'; - case _i43.RedAlertResult(): + case _i45.RedAlertResult(): return 'RedAlertResult'; - case _i44.ServiceHealth(): + case _i46.ServiceHealth(): return 'ServiceHealth'; - case _i45.TermsChangeNotice(): + case _i47.TermsChangeNotice(): return 'TermsChangeNotice'; - case _i46.TermsVersionEntry(): + case _i48.TermsVersionEntry(): return 'TermsVersionEntry'; - case _i47.TotpEnrollmentStart(): + case _i49.TotpEnrollmentStart(): return 'TotpEnrollmentStart'; - case _i48.TriageResult(): + case _i50.TriageResult(): return 'TriageResult'; - case _i49.UbsContact(): + case _i51.UbsContact(): return 'UbsContact'; - case _i50.VisitSyncEntry(): + case _i52.VisitSyncEntry(): return 'VisitSyncEntry'; - case _i51.VisitSyncResult(): + case _i53.VisitSyncResult(): return 'VisitSyncResult'; - case _i52.AuditChainCheckpoint(): + case _i54.AuditChainCheckpoint(): return 'AuditChainCheckpoint'; - case _i53.AuditExport(): + case _i55.AuditExport(): return 'AuditExport'; - case _i54.AuditLog(): + case _i56.AuditLog(): return 'AuditLog'; - case _i55.ConsentLog(): + case _i57.ConsentLog(): return 'ConsentLog'; - case _i56.DataSubjectRequest(): + case _i58.DataSubjectRequest(): return 'DataSubjectRequest'; - case _i57.EnrollmentToken(): + case _i59.DeviceWipeOrder(): + return 'DeviceWipeOrder'; + case _i60.EnrollmentToken(): return 'EnrollmentToken'; - case _i58.AlertStatus(): + case _i61.AlertStatus(): return 'AlertStatus'; - case _i59.ArrivalMethod(): + case _i62.ArrivalMethod(): return 'ArrivalMethod'; - case _i60.AuditChainReason(): + case _i63.AuditChainReason(): return 'AuditChainReason'; - case _i61.AuditIntegrityState(): + case _i64.AuditIntegrityState(): return 'AuditIntegrityState'; - case _i62.AuditRowSeal(): + case _i65.AuditRowSeal(): return 'AuditRowSeal'; - case _i63.ConsentPurpose(): + case _i66.ConsentPurpose(): return 'ConsentPurpose'; - case _i64.DataSubjectRequestStatus(): + case _i67.DataSubjectRequestStatus(): return 'DataSubjectRequestStatus'; - case _i65.DataSubjectRequestType(): + case _i68.DataSubjectRequestType(): return 'DataSubjectRequestType'; - case _i66.EscalationReason(): + case _i69.DeviceWipeReason(): + return 'DeviceWipeReason'; + case _i70.DeviceWipeState(): + return 'DeviceWipeState'; + case _i71.EscalationReason(): return 'EscalationReason'; - case _i67.RiskLevel(): + case _i72.RiskLevel(): return 'RiskLevel'; - case _i68.ScheduledNoticeStatus(): + case _i73.ScheduledNoticeStatus(): return 'ScheduledNoticeStatus'; - case _i69.SyncStatus(): + case _i74.SyncStatus(): return 'SyncStatus'; - case _i70.UserRole(): + case _i75.UserRole(): return 'UserRole'; - case _i71.VisitAuthorship(): + case _i76.VisitAuthorship(): return 'VisitAuthorship'; - case _i72.AdminInvalidRequestException(): + case _i77.AdminInvalidRequestException(): return 'AdminInvalidRequestException'; - case _i73.AlertDispatchUnavailableException(): + case _i78.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i74.AlertPermissionException(): + case _i79.AlertPermissionException(): return 'AlertPermissionException'; - case _i75.AlertValidationException(): + case _i80.AlertValidationException(): return 'AlertValidationException'; - case _i76.AuthenticationFailedException(): + case _i81.AuthenticationFailedException(): return 'AuthenticationFailedException'; - case _i77.DataRightsException(): + case _i82.DataRightsException(): return 'DataRightsException'; - case _i78.EndpointDisabledException(): + case _i83.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i79.EnrollmentException(): + case _i84.EnrollmentException(): return 'EnrollmentException'; - case _i80.MfaEnrollmentRequiredException(): + case _i85.MfaEnrollmentRequiredException(): return 'MfaEnrollmentRequiredException'; - case _i81.MfaRequiredException(): + case _i86.MfaRequiredException(): return 'MfaRequiredException'; - case _i82.NoticeDeliveryException(): + case _i87.NoticeDeliveryException(): return 'NoticeDeliveryException'; - case _i83.OtpRequestException(): + case _i88.OtpRequestException(): return 'OtpRequestException'; - case _i84.SessionExpiredException(): + case _i89.SessionExpiredException(): return 'SessionExpiredException'; - case _i85.MicroArea(): + case _i90.MicroArea(): return 'MicroArea'; - case _i86.OtpChallenge(): + case _i91.OtpChallenge(): return 'OtpChallenge'; - case _i87.Patient(): + case _i92.Patient(): return 'Patient'; - case _i88.PushToken(): + case _i93.PushToken(): return 'PushToken'; - case _i89.ScheduledNotice(): + case _i94.ScheduledNotice(): return 'ScheduledNotice'; - case _i90.StaffAccount(): + case _i95.StaffAccount(): return 'StaffAccount'; - case _i91.TermsVersion(): + case _i96.TermsVersion(): return 'TermsVersion'; - case _i92.TriageAnswer(): + case _i97.TriageAnswer(): return 'TriageAnswer'; - case _i93.TriageSession(): + case _i98.TriageSession(): return 'TriageSession'; - case _i94.Ubs(): + case _i99.Ubs(): return 'Ubs'; - case _i95.User(): + case _i100.User(): return 'User'; - case _i96.UserCredential(): + case _i101.UserCredential(): return 'UserCredential'; - case _i97.Visit(): + case _i102.Visit(): return 'Visit'; } return null; @@ -1337,263 +1402,278 @@ class Protocol extends _i1.SerializationManager { if (dataClassName == 'AdminAcsCreationResult') { return deserialize<_i11.AdminAcsCreationResult>(data['data']); } + if (dataClassName == 'AdminAcsDevice') { + return deserialize<_i12.AdminAcsDevice>(data['data']); + } if (dataClassName == 'AdminAlert') { - return deserialize<_i12.AdminAlert>(data['data']); + return deserialize<_i13.AdminAlert>(data['data']); } if (dataClassName == 'AdminAlertPage') { - return deserialize<_i13.AdminAlertPage>(data['data']); + return deserialize<_i14.AdminAlertPage>(data['data']); } if (dataClassName == 'AdminAlertsPulse') { - return deserialize<_i14.AdminAlertsPulse>(data['data']); + return deserialize<_i15.AdminAlertsPulse>(data['data']); } if (dataClassName == 'AdminAuditChainStatus') { - return deserialize<_i15.AdminAuditChainStatus>(data['data']); + return deserialize<_i16.AdminAuditChainStatus>(data['data']); } if (dataClassName == 'AdminAuditEntry') { - return deserialize<_i16.AdminAuditEntry>(data['data']); + return deserialize<_i17.AdminAuditEntry>(data['data']); } if (dataClassName == 'AdminAuditExport') { - return deserialize<_i17.AdminAuditExport>(data['data']); + return deserialize<_i18.AdminAuditExport>(data['data']); } if (dataClassName == 'AdminAuditFilter') { - return deserialize<_i18.AdminAuditFilter>(data['data']); + return deserialize<_i19.AdminAuditFilter>(data['data']); } if (dataClassName == 'AdminAuditPage') { - return deserialize<_i19.AdminAuditPage>(data['data']); + return deserialize<_i20.AdminAuditPage>(data['data']); } if (dataClassName == 'AdminDataSubjectRequest') { - return deserialize<_i20.AdminDataSubjectRequest>(data['data']); + return deserialize<_i21.AdminDataSubjectRequest>(data['data']); } if (dataClassName == 'AdminDataSubjectRequestDetail') { - return deserialize<_i21.AdminDataSubjectRequestDetail>(data['data']); + return deserialize<_i22.AdminDataSubjectRequestDetail>(data['data']); } if (dataClassName == 'AdminDataSubjectRequestPage') { - return deserialize<_i22.AdminDataSubjectRequestPage>(data['data']); + return deserialize<_i23.AdminDataSubjectRequestPage>(data['data']); + } + if (dataClassName == 'AdminDeviceWipeResult') { + return deserialize<_i24.AdminDeviceWipeResult>(data['data']); } if (dataClassName == 'AdminEscalationResult') { - return deserialize<_i23.AdminEscalationResult>(data['data']); + return deserialize<_i25.AdminEscalationResult>(data['data']); } if (dataClassName == 'AdminIndicators') { - return deserialize<_i24.AdminIndicators>(data['data']); + return deserialize<_i26.AdminIndicators>(data['data']); } if (dataClassName == 'AdminMicroArea') { - return deserialize<_i25.AdminMicroArea>(data['data']); + return deserialize<_i27.AdminMicroArea>(data['data']); } if (dataClassName == 'AdminPasswordResetResult') { - return deserialize<_i26.AdminPasswordResetResult>(data['data']); + return deserialize<_i28.AdminPasswordResetResult>(data['data']); } if (dataClassName == 'AdminStaff') { - return deserialize<_i27.AdminStaff>(data['data']); + return deserialize<_i29.AdminStaff>(data['data']); } if (dataClassName == 'AdminStaffMfaResetResult') { - return deserialize<_i28.AdminStaffMfaResetResult>(data['data']); + return deserialize<_i30.AdminStaffMfaResetResult>(data['data']); } if (dataClassName == 'AlertAckResult') { - return deserialize<_i29.AlertAckResult>(data['data']); + return deserialize<_i31.AlertAckResult>(data['data']); } if (dataClassName == 'AlertResolveResult') { - return deserialize<_i30.AlertResolveResult>(data['data']); + return deserialize<_i32.AlertResolveResult>(data['data']); } if (dataClassName == 'AlertStatusResult') { - return deserialize<_i31.AlertStatusResult>(data['data']); + return deserialize<_i33.AlertStatusResult>(data['data']); } if (dataClassName == 'DevelopmentLoginResult') { - return deserialize<_i32.DevelopmentLoginResult>(data['data']); + return deserialize<_i34.DevelopmentLoginResult>(data['data']); } if (dataClassName == 'EnrollmentResult') { - return deserialize<_i33.EnrollmentResult>(data['data']); + return deserialize<_i35.EnrollmentResult>(data['data']); } if (dataClassName == 'EnrollmentTokenResult') { - return deserialize<_i34.EnrollmentTokenResult>(data['data']); + return deserialize<_i36.EnrollmentTokenResult>(data['data']); } if (dataClassName == 'MicroAreaPatient') { - return deserialize<_i35.MicroAreaPatient>(data['data']); + return deserialize<_i37.MicroAreaPatient>(data['data']); } if (dataClassName == 'NoticeHistoryEntry') { - return deserialize<_i36.NoticeHistoryEntry>(data['data']); + return deserialize<_i38.NoticeHistoryEntry>(data['data']); } if (dataClassName == 'NoticeHistoryPage') { - return deserialize<_i37.NoticeHistoryPage>(data['data']); + return deserialize<_i39.NoticeHistoryPage>(data['data']); } if (dataClassName == 'NoticeSendResult') { - return deserialize<_i38.NoticeSendResult>(data['data']); + return deserialize<_i40.NoticeSendResult>(data['data']); } if (dataClassName == 'PatientConsentRecord') { - return deserialize<_i39.PatientConsentRecord>(data['data']); + return deserialize<_i41.PatientConsentRecord>(data['data']); } if (dataClassName == 'PatientDataOverview') { - return deserialize<_i40.PatientDataOverview>(data['data']); + return deserialize<_i42.PatientDataOverview>(data['data']); } if (dataClassName == 'PatientDataSubjectRequestRecord') { - return deserialize<_i41.PatientDataSubjectRequestRecord>(data['data']); + return deserialize<_i43.PatientDataSubjectRequestRecord>(data['data']); } if (dataClassName == 'PatientRiskEvent') { - return deserialize<_i42.PatientRiskEvent>(data['data']); + return deserialize<_i44.PatientRiskEvent>(data['data']); } if (dataClassName == 'RedAlertResult') { - return deserialize<_i43.RedAlertResult>(data['data']); + return deserialize<_i45.RedAlertResult>(data['data']); } if (dataClassName == 'ServiceHealth') { - return deserialize<_i44.ServiceHealth>(data['data']); + return deserialize<_i46.ServiceHealth>(data['data']); } if (dataClassName == 'TermsChangeNotice') { - return deserialize<_i45.TermsChangeNotice>(data['data']); + return deserialize<_i47.TermsChangeNotice>(data['data']); } if (dataClassName == 'TermsVersionEntry') { - return deserialize<_i46.TermsVersionEntry>(data['data']); + return deserialize<_i48.TermsVersionEntry>(data['data']); } if (dataClassName == 'TotpEnrollmentStart') { - return deserialize<_i47.TotpEnrollmentStart>(data['data']); + return deserialize<_i49.TotpEnrollmentStart>(data['data']); } if (dataClassName == 'TriageResult') { - return deserialize<_i48.TriageResult>(data['data']); + return deserialize<_i50.TriageResult>(data['data']); } if (dataClassName == 'UbsContact') { - return deserialize<_i49.UbsContact>(data['data']); + return deserialize<_i51.UbsContact>(data['data']); } if (dataClassName == 'VisitSyncEntry') { - return deserialize<_i50.VisitSyncEntry>(data['data']); + return deserialize<_i52.VisitSyncEntry>(data['data']); } if (dataClassName == 'VisitSyncResult') { - return deserialize<_i51.VisitSyncResult>(data['data']); + return deserialize<_i53.VisitSyncResult>(data['data']); } if (dataClassName == 'AuditChainCheckpoint') { - return deserialize<_i52.AuditChainCheckpoint>(data['data']); + return deserialize<_i54.AuditChainCheckpoint>(data['data']); } if (dataClassName == 'AuditExport') { - return deserialize<_i53.AuditExport>(data['data']); + return deserialize<_i55.AuditExport>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i54.AuditLog>(data['data']); + return deserialize<_i56.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i55.ConsentLog>(data['data']); + return deserialize<_i57.ConsentLog>(data['data']); } if (dataClassName == 'DataSubjectRequest') { - return deserialize<_i56.DataSubjectRequest>(data['data']); + return deserialize<_i58.DataSubjectRequest>(data['data']); + } + if (dataClassName == 'DeviceWipeOrder') { + return deserialize<_i59.DeviceWipeOrder>(data['data']); } if (dataClassName == 'EnrollmentToken') { - return deserialize<_i57.EnrollmentToken>(data['data']); + return deserialize<_i60.EnrollmentToken>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i58.AlertStatus>(data['data']); + return deserialize<_i61.AlertStatus>(data['data']); } if (dataClassName == 'ArrivalMethod') { - return deserialize<_i59.ArrivalMethod>(data['data']); + return deserialize<_i62.ArrivalMethod>(data['data']); } if (dataClassName == 'AuditChainReason') { - return deserialize<_i60.AuditChainReason>(data['data']); + return deserialize<_i63.AuditChainReason>(data['data']); } if (dataClassName == 'AuditIntegrityState') { - return deserialize<_i61.AuditIntegrityState>(data['data']); + return deserialize<_i64.AuditIntegrityState>(data['data']); } if (dataClassName == 'AuditRowSeal') { - return deserialize<_i62.AuditRowSeal>(data['data']); + return deserialize<_i65.AuditRowSeal>(data['data']); } if (dataClassName == 'ConsentPurpose') { - return deserialize<_i63.ConsentPurpose>(data['data']); + return deserialize<_i66.ConsentPurpose>(data['data']); } if (dataClassName == 'DataSubjectRequestStatus') { - return deserialize<_i64.DataSubjectRequestStatus>(data['data']); + return deserialize<_i67.DataSubjectRequestStatus>(data['data']); } if (dataClassName == 'DataSubjectRequestType') { - return deserialize<_i65.DataSubjectRequestType>(data['data']); + return deserialize<_i68.DataSubjectRequestType>(data['data']); + } + if (dataClassName == 'DeviceWipeReason') { + return deserialize<_i69.DeviceWipeReason>(data['data']); + } + if (dataClassName == 'DeviceWipeState') { + return deserialize<_i70.DeviceWipeState>(data['data']); } if (dataClassName == 'EscalationReason') { - return deserialize<_i66.EscalationReason>(data['data']); + return deserialize<_i71.EscalationReason>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i67.RiskLevel>(data['data']); + return deserialize<_i72.RiskLevel>(data['data']); } if (dataClassName == 'ScheduledNoticeStatus') { - return deserialize<_i68.ScheduledNoticeStatus>(data['data']); + return deserialize<_i73.ScheduledNoticeStatus>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i69.SyncStatus>(data['data']); + return deserialize<_i74.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i70.UserRole>(data['data']); + return deserialize<_i75.UserRole>(data['data']); } if (dataClassName == 'VisitAuthorship') { - return deserialize<_i71.VisitAuthorship>(data['data']); + return deserialize<_i76.VisitAuthorship>(data['data']); } if (dataClassName == 'AdminInvalidRequestException') { - return deserialize<_i72.AdminInvalidRequestException>(data['data']); + return deserialize<_i77.AdminInvalidRequestException>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i73.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i78.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i74.AlertPermissionException>(data['data']); + return deserialize<_i79.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i75.AlertValidationException>(data['data']); + return deserialize<_i80.AlertValidationException>(data['data']); } if (dataClassName == 'AuthenticationFailedException') { - return deserialize<_i76.AuthenticationFailedException>(data['data']); + return deserialize<_i81.AuthenticationFailedException>(data['data']); } if (dataClassName == 'DataRightsException') { - return deserialize<_i77.DataRightsException>(data['data']); + return deserialize<_i82.DataRightsException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i78.EndpointDisabledException>(data['data']); + return deserialize<_i83.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i79.EnrollmentException>(data['data']); + return deserialize<_i84.EnrollmentException>(data['data']); } if (dataClassName == 'MfaEnrollmentRequiredException') { - return deserialize<_i80.MfaEnrollmentRequiredException>(data['data']); + return deserialize<_i85.MfaEnrollmentRequiredException>(data['data']); } if (dataClassName == 'MfaRequiredException') { - return deserialize<_i81.MfaRequiredException>(data['data']); + return deserialize<_i86.MfaRequiredException>(data['data']); } if (dataClassName == 'NoticeDeliveryException') { - return deserialize<_i82.NoticeDeliveryException>(data['data']); + return deserialize<_i87.NoticeDeliveryException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i83.OtpRequestException>(data['data']); + return deserialize<_i88.OtpRequestException>(data['data']); } if (dataClassName == 'SessionExpiredException') { - return deserialize<_i84.SessionExpiredException>(data['data']); + return deserialize<_i89.SessionExpiredException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i85.MicroArea>(data['data']); + return deserialize<_i90.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i86.OtpChallenge>(data['data']); + return deserialize<_i91.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i87.Patient>(data['data']); + return deserialize<_i92.Patient>(data['data']); } if (dataClassName == 'PushToken') { - return deserialize<_i88.PushToken>(data['data']); + return deserialize<_i93.PushToken>(data['data']); } if (dataClassName == 'ScheduledNotice') { - return deserialize<_i89.ScheduledNotice>(data['data']); + return deserialize<_i94.ScheduledNotice>(data['data']); } if (dataClassName == 'StaffAccount') { - return deserialize<_i90.StaffAccount>(data['data']); + return deserialize<_i95.StaffAccount>(data['data']); } if (dataClassName == 'TermsVersion') { - return deserialize<_i91.TermsVersion>(data['data']); + return deserialize<_i96.TermsVersion>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i92.TriageAnswer>(data['data']); + return deserialize<_i97.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i93.TriageSession>(data['data']); + return deserialize<_i98.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i94.Ubs>(data['data']); + return deserialize<_i99.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i95.User>(data['data']); + return deserialize<_i100.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i96.UserCredential>(data['data']); + return deserialize<_i101.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i97.Visit>(data['data']); + return deserialize<_i102.Visit>(data['data']); } return super.deserializeByClassName(data); } diff --git a/backend/sinalacs_server/lib/src/application/devices/device_wipe_service.dart b/backend/sinalacs_server/lib/src/application/devices/device_wipe_service.dart new file mode 100644 index 0000000..ee8071a --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/devices/device_wipe_service.dart @@ -0,0 +1,168 @@ +import 'package:sinalacs_server/src/application/admin/admin_account_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Pedidos de limpeza remota e aparelhos do ACS sobre o banco (issue #81). +/// Interface à parte do ORM, no espírito de `AdminAccountStore`. +abstract interface class DeviceWipeStore { + /// Aparelhos do ACS [acsId] (os que têm refresh token ou token de envio), + /// do visto por último ao mais antigo, com o estado do pedido de limpeza. + /// + /// Devolve também a sentinela `deviceIdAbsent`, se houver: quem a esconde é + /// o serviço, que é quem conhece a regra. + Future> devicesOf(String acsId); + + /// `true` se existe linha em `acs_refresh_tokens` ou `acs_upload_tokens` com + /// `userId = acsId` e esse [deviceId]. + Future deviceBelongsTo(String acsId, String deviceId); + + /// Grava o pedido de limpeza e revoga os tokens do aparelho numa **única + /// transação**: insere (ou lê, se o índice único de `deviceId` já tem linha) + /// o pedido e, só quando [created], carimba `revokedAt = at` em toda linha + /// viva de `acs_refresh_tokens` e `acs_upload_tokens` com esse `deviceId` — + /// de todos os donos. Repetir não cria linha nem revoga de novo. + Future<({DeviceWipeOrder order, bool created})> orderWipe({ + required String deviceId, + required String targetUserId, + required String requestedBy, + required DeviceWipeReason reason, + required DateTime at, + }); + + /// O pedido de limpeza do aparelho, ou `null`. + Future orderFor(String deviceId); + + /// Carimba `deliveredAt`. `true` só na primeira vez. + Future markDelivered(String orderId, DateTime at); + + /// Carimba `confirmedAt`. `true` só na primeira vez e só se o par + /// ([deviceId], [orderId]) for o do pedido. + Future markConfirmed(String deviceId, String orderId, DateTime at); +} + +/// Limpeza remota do aparelho de um ACS pelo backoffice (issue #81). +/// +/// Papel e escopo vêm do [AdminScopeResolver]: só `coordinator`/`admin`; o +/// coordenador opera **apenas** ACS da própria UBS. ACS fora do escopo, ACS +/// inexistente, aparelho que não é do ACS e a sentinela +/// `InstitutionalAuthService.deviceIdAbsent` recebem a **mesma** recusa +/// (`Aparelho não encontrado.`), com a linha `denied` na trilha antes de lançar. +/// +/// Auditoria fail-closed, sempre com `record`: a listagem audita `read`/ +/// `admin_acs_devices` antes de consultar; o pedido audita `write`/ +/// `admin_device` depois do commit (`wipe_ordered` ou `already_ordered`), com o +/// id do pedido em `resourceId`. +class DeviceWipeService { + DeviceWipeService({ + required this.store, + required this.accounts, + required this.audit, + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; + + final DeviceWipeStore store; + + /// Escopo e existência do ACS (`acsById`). + final AdminAccountStore accounts; + + final AuditTrail audit; + final DateTime Function() _clock; + + late final AdminScopeResolver _resolver = AdminScopeResolver( + store: accounts, + audit: audit, + ); + + static const _naoEncontrado = 'Aparelho não encontrado.'; + + /// Aparelhos do ACS, sem a sentinela. + Future> devicesOf( + AuthenticatedUser user, { + required String acsId, + }) async { + const recurso = 'admin_acs_devices'; + final escopo = await _resolver.resolve(user, recurso: recurso); + if (await accounts.acsById(escopo, acsId) == null) { + await _negar(user, recurso, actionType: 'read'); + } + await audit.record( + AuditEvent( + userId: user.id, + actionType: 'read', + resourceType: recurso, + resourceId: acsId, + result: 'success', + ), + ); + final todos = await store.devicesOf(acsId); + return [ + for (final d in todos) + if (d.deviceId != InstitutionalAuthService.deviceIdAbsent) d, + ]; + } + + /// Pede a limpeza do aparelho [deviceId] do ACS [acsId] e revoga os tokens + /// dele. Repetir é idempotente: `alreadyOrdered` e o `requestedAt` original. + Future orderWipe( + AuthenticatedUser user, { + required String acsId, + required String deviceId, + required DeviceWipeReason reason, + }) async { + const recurso = 'admin_device'; + final escopo = await _resolver.resolve( + user, + recurso: 'admin_acs', + actionType: 'write', + ); + if (await accounts.acsById(escopo, acsId) == null || + deviceId.trim().isEmpty || + deviceId == InstitutionalAuthService.deviceIdAbsent || + !await store.deviceBelongsTo(acsId, deviceId)) { + await _negar(user, recurso, actionType: 'write'); + } + + final r = await store.orderWipe( + deviceId: deviceId, + targetUserId: acsId, + requestedBy: user.id, + reason: reason, + at: _clock().toUtc(), + ); + final orderId = r.order.id.toString(); + await audit.record( + AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: recurso, + resourceId: orderId, + result: r.created ? 'wipe_ordered' : 'already_ordered', + ), + ); + return AdminDeviceWipeResult( + orderId: orderId, + requestedAt: r.order.requestedAt, + alreadyOrdered: !r.created, + ); + } + + /// Recusa com linha `denied` ANTES de lançar; a mesma mensagem para "não + /// existe" e "não é seu". + Future _negar( + AuthenticatedUser user, + String recurso, { + required String actionType, + }) async { + await audit.record( + AuditEvent( + userId: user.id, + actionType: actionType, + resourceType: recurso, + result: 'denied', + ), + ); + throw AdminInvalidRequestException(message: _naoEncontrado); + } +} diff --git a/backend/sinalacs_server/lib/src/endpoints/admin_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/admin_endpoint.dart index bd20979..c70f585 100644 --- a/backend/sinalacs_server/lib/src/endpoints/admin_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/admin_endpoint.dart @@ -286,6 +286,37 @@ class AdminEndpoint extends AuthenticatedEndpoint { .adminAccountServiceFor(session) .resetStaffMfa(authenticate(accessToken), staffId: staffId); + /// Aparelhos do ACS [acsId] (#81): rótulo, último uso e estado do pedido de + /// limpeza. Só `coordinator`/`admin`; o coordenador só na própria UBS. ACS + /// fora do escopo e inexistente recebem a mesma recusa; a leitura é auditada. + Future> acsDevices( + Session session, { + required String accessToken, + required String acsId, + }) => AlertRuntime.instance + .deviceWipeServiceFor(session) + .devicesOf(authenticate(accessToken), acsId: acsId); + + /// Pede a limpeza remota do aparelho [deviceId] do ACS [acsId] (#81) e revoga + /// na hora os tokens dele, numa transação só. O aparelho precisa ser do ACS e + /// do escopo de quem pede; qualquer outro caso recebe a mesma recusa. Repetir + /// é idempotente (`alreadyOrdered`, carimbo original). Audita + /// `admin_device`/`wipe_ordered` com o id do pedido. + Future orderDeviceWipe( + Session session, { + required String accessToken, + required String acsId, + required String deviceId, + required DeviceWipeReason reason, + }) => AlertRuntime.instance + .deviceWipeServiceFor(session) + .orderWipe( + authenticate(accessToken), + acsId: acsId, + deviceId: deviceId, + reason: reason, + ); + /// Escalona um alerta vermelho pendente cujo prazo sem confirmação venceu /// (#44). Grava data, autor e motivo fechado, reenfileira **uma** entrega de /// reforço para o tópico da microárea e audita — numa transação só; o alerta diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_acs_device.dart b/backend/sinalacs_server/lib/src/generated/api/admin_acs_device.dart new file mode 100644 index 0000000..910a27a --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_acs_device.dart @@ -0,0 +1,166 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod/serverpod.dart' as _i1; +import '../enums/device_wipe_state.dart' as _i2; + +/// Aparelho de um ACS na listagem do backoffice (#81). O rótulo é derivado do +/// `deviceId` (`Aparelho ` + 8 primeiros caracteres): o app não envia modelo +/// nem nome do aparelho, e o id completo não precisa chegar à tela. +abstract class AdminAcsDevice + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminAcsDevice._({ + required this.deviceId, + required this.label, + required this.lastSeenAt, + this.wipeState, + this.wipeRequestedAt, + this.wipeConfirmedAt, + }); + + factory AdminAcsDevice({ + required String deviceId, + required String label, + required DateTime lastSeenAt, + _i2.DeviceWipeState? wipeState, + DateTime? wipeRequestedAt, + DateTime? wipeConfirmedAt, + }) = _AdminAcsDeviceImpl; + + factory AdminAcsDevice.fromJson(Map jsonSerialization) { + return AdminAcsDevice( + deviceId: jsonSerialization['deviceId'] as String, + label: jsonSerialization['label'] as String, + lastSeenAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['lastSeenAt'], + ), + wipeState: jsonSerialization['wipeState'] == null + ? null + : _i2.DeviceWipeState.fromJson( + (jsonSerialization['wipeState'] as String), + ), + wipeRequestedAt: jsonSerialization['wipeRequestedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['wipeRequestedAt'], + ), + wipeConfirmedAt: jsonSerialization['wipeConfirmedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['wipeConfirmedAt'], + ), + ); + } + + String deviceId; + + String label; + + /// Maior `issuedAt` entre os tokens do aparelho. + DateTime lastSeenAt; + + /// `null` = sem pedido de limpeza. + _i2.DeviceWipeState? wipeState; + + DateTime? wipeRequestedAt; + + DateTime? wipeConfirmedAt; + + /// Returns a shallow copy of this [AdminAcsDevice] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAcsDevice copyWith({ + String? deviceId, + String? label, + DateTime? lastSeenAt, + _i2.DeviceWipeState? wipeState, + DateTime? wipeRequestedAt, + DateTime? wipeConfirmedAt, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAcsDevice', + 'deviceId': deviceId, + 'label': label, + 'lastSeenAt': lastSeenAt.toJson(), + if (wipeState != null) 'wipeState': wipeState?.toJson(), + if (wipeRequestedAt != null) 'wipeRequestedAt': wipeRequestedAt?.toJson(), + if (wipeConfirmedAt != null) 'wipeConfirmedAt': wipeConfirmedAt?.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminAcsDevice', + 'deviceId': deviceId, + 'label': label, + 'lastSeenAt': lastSeenAt.toJson(), + if (wipeState != null) 'wipeState': wipeState?.toJson(), + if (wipeRequestedAt != null) 'wipeRequestedAt': wipeRequestedAt?.toJson(), + if (wipeConfirmedAt != null) 'wipeConfirmedAt': wipeConfirmedAt?.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAcsDeviceImpl extends AdminAcsDevice { + _AdminAcsDeviceImpl({ + required String deviceId, + required String label, + required DateTime lastSeenAt, + _i2.DeviceWipeState? wipeState, + DateTime? wipeRequestedAt, + DateTime? wipeConfirmedAt, + }) : super._( + deviceId: deviceId, + label: label, + lastSeenAt: lastSeenAt, + wipeState: wipeState, + wipeRequestedAt: wipeRequestedAt, + wipeConfirmedAt: wipeConfirmedAt, + ); + + /// Returns a shallow copy of this [AdminAcsDevice] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAcsDevice copyWith({ + String? deviceId, + String? label, + DateTime? lastSeenAt, + Object? wipeState = _Undefined, + Object? wipeRequestedAt = _Undefined, + Object? wipeConfirmedAt = _Undefined, + }) { + return AdminAcsDevice( + deviceId: deviceId ?? this.deviceId, + label: label ?? this.label, + lastSeenAt: lastSeenAt ?? this.lastSeenAt, + wipeState: wipeState is _i2.DeviceWipeState? ? wipeState : this.wipeState, + wipeRequestedAt: wipeRequestedAt is DateTime? + ? wipeRequestedAt + : this.wipeRequestedAt, + wipeConfirmedAt: wipeConfirmedAt is DateTime? + ? wipeConfirmedAt + : this.wipeConfirmedAt, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_device_wipe_result.dart b/backend/sinalacs_server/lib/src/generated/api/admin_device_wipe_result.dart new file mode 100644 index 0000000..aac3ced --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_device_wipe_result.dart @@ -0,0 +1,112 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod/serverpod.dart' as _i1; + +/// Resposta do pedido de limpeza remota (#81). Repetir o pedido é idempotente: +/// `alreadyOrdered` e o `requestedAt` original. +abstract class AdminDeviceWipeResult + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminDeviceWipeResult._({ + required this.orderId, + required this.requestedAt, + required this.alreadyOrdered, + }); + + factory AdminDeviceWipeResult({ + required String orderId, + required DateTime requestedAt, + required bool alreadyOrdered, + }) = _AdminDeviceWipeResultImpl; + + factory AdminDeviceWipeResult.fromJson( + Map jsonSerialization, + ) { + return AdminDeviceWipeResult( + orderId: jsonSerialization['orderId'] as String, + requestedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['requestedAt'], + ), + alreadyOrdered: _i1.BoolJsonExtension.fromJson( + jsonSerialization['alreadyOrdered'], + ), + ); + } + + String orderId; + + DateTime requestedAt; + + bool alreadyOrdered; + + /// Returns a shallow copy of this [AdminDeviceWipeResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminDeviceWipeResult copyWith({ + String? orderId, + DateTime? requestedAt, + bool? alreadyOrdered, + }); + @override + Map toJson() { + return { + '__className__': 'AdminDeviceWipeResult', + 'orderId': orderId, + 'requestedAt': requestedAt.toJson(), + 'alreadyOrdered': alreadyOrdered, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminDeviceWipeResult', + 'orderId': orderId, + 'requestedAt': requestedAt.toJson(), + 'alreadyOrdered': alreadyOrdered, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminDeviceWipeResultImpl extends AdminDeviceWipeResult { + _AdminDeviceWipeResultImpl({ + required String orderId, + required DateTime requestedAt, + required bool alreadyOrdered, + }) : super._( + orderId: orderId, + requestedAt: requestedAt, + alreadyOrdered: alreadyOrdered, + ); + + /// Returns a shallow copy of this [AdminDeviceWipeResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminDeviceWipeResult copyWith({ + String? orderId, + DateTime? requestedAt, + bool? alreadyOrdered, + }) { + return AdminDeviceWipeResult( + orderId: orderId ?? this.orderId, + requestedAt: requestedAt ?? this.requestedAt, + alreadyOrdered: alreadyOrdered ?? this.alreadyOrdered, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/device_wipe_order.dart b/backend/sinalacs_server/lib/src/generated/device_wipe_order.dart new file mode 100644 index 0000000..181a8f4 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/device_wipe_order.dart @@ -0,0 +1,660 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod/serverpod.dart' as _i1; +import 'enums/device_wipe_reason.dart' as _i2; + +/// Pedido de limpeza remota do aparelho de um ACS (#81). Um por aparelho +/// (índice único em `deviceId`): pedir de novo devolve o pedido original. +/// +/// O pedido não carrega dado pessoal: só ids, o motivo (conjunto fechado) e os +/// carimbos. `deliveredAt` marca a primeira vez que o aparelho foi mandado +/// limpar; `confirmedAt`, a confirmação do próprio aparelho de que limpou. +abstract class DeviceWipeOrder + implements _i1.TableRow<_i1.UuidValue?>, _i1.ProtocolSerialization { + DeviceWipeOrder._({ + this.id, + required this.deviceId, + required this.targetUserId, + required this.requestedBy, + required this.reason, + required this.requestedAt, + this.deliveredAt, + this.confirmedAt, + }); + + factory DeviceWipeOrder({ + _i1.UuidValue? id, + required String deviceId, + required _i1.UuidValue targetUserId, + required _i1.UuidValue requestedBy, + required _i2.DeviceWipeReason reason, + required DateTime requestedAt, + DateTime? deliveredAt, + DateTime? confirmedAt, + }) = _DeviceWipeOrderImpl; + + factory DeviceWipeOrder.fromJson(Map jsonSerialization) { + return DeviceWipeOrder( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + deviceId: jsonSerialization['deviceId'] as String, + targetUserId: _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['targetUserId'], + ), + requestedBy: _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['requestedBy'], + ), + reason: _i2.DeviceWipeReason.fromJson( + (jsonSerialization['reason'] as String), + ), + requestedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['requestedAt'], + ), + deliveredAt: jsonSerialization['deliveredAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['deliveredAt'], + ), + confirmedAt: jsonSerialization['confirmedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['confirmedAt'], + ), + ); + } + + static final t = DeviceWipeOrderTable(); + + static const db = DeviceWipeOrderRepository._(); + + @override + _i1.UuidValue? id; + + /// Instalação alvo (`acs_refresh_tokens.deviceId`). + String deviceId; + + /// O ACS dono do aparelho no momento do pedido. + _i1.UuidValue targetUserId; + + /// Quem pediu (coordenador ou administrador). + _i1.UuidValue requestedBy; + + _i2.DeviceWipeReason reason; + + DateTime requestedAt; + + DateTime? deliveredAt; + + DateTime? confirmedAt; + + @override + _i1.Table<_i1.UuidValue?> get table => t; + + /// Returns a shallow copy of this [DeviceWipeOrder] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + DeviceWipeOrder copyWith({ + _i1.UuidValue? id, + String? deviceId, + _i1.UuidValue? targetUserId, + _i1.UuidValue? requestedBy, + _i2.DeviceWipeReason? reason, + DateTime? requestedAt, + DateTime? deliveredAt, + DateTime? confirmedAt, + }); + @override + Map toJson() { + return { + '__className__': 'DeviceWipeOrder', + if (id != null) 'id': id?.toJson(), + 'deviceId': deviceId, + 'targetUserId': targetUserId.toJson(), + 'requestedBy': requestedBy.toJson(), + 'reason': reason.toJson(), + 'requestedAt': requestedAt.toJson(), + if (deliveredAt != null) 'deliveredAt': deliveredAt?.toJson(), + if (confirmedAt != null) 'confirmedAt': confirmedAt?.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'DeviceWipeOrder', + if (id != null) 'id': id?.toJson(), + 'deviceId': deviceId, + 'targetUserId': targetUserId.toJson(), + 'requestedBy': requestedBy.toJson(), + 'reason': reason.toJson(), + 'requestedAt': requestedAt.toJson(), + if (deliveredAt != null) 'deliveredAt': deliveredAt?.toJson(), + if (confirmedAt != null) 'confirmedAt': confirmedAt?.toJson(), + }; + } + + static DeviceWipeOrderInclude include() { + return DeviceWipeOrderInclude._(); + } + + static DeviceWipeOrderIncludeList includeList({ + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + DeviceWipeOrderInclude? include, + }) { + return DeviceWipeOrderIncludeList._( + where: where, + limit: limit, + offset: offset, + orderBy: orderBy?.call(DeviceWipeOrder.t), + orderDescending: orderDescending, + orderByList: orderByList?.call(DeviceWipeOrder.t), + include: include, + ); + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _DeviceWipeOrderImpl extends DeviceWipeOrder { + _DeviceWipeOrderImpl({ + _i1.UuidValue? id, + required String deviceId, + required _i1.UuidValue targetUserId, + required _i1.UuidValue requestedBy, + required _i2.DeviceWipeReason reason, + required DateTime requestedAt, + DateTime? deliveredAt, + DateTime? confirmedAt, + }) : super._( + id: id, + deviceId: deviceId, + targetUserId: targetUserId, + requestedBy: requestedBy, + reason: reason, + requestedAt: requestedAt, + deliveredAt: deliveredAt, + confirmedAt: confirmedAt, + ); + + /// Returns a shallow copy of this [DeviceWipeOrder] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + DeviceWipeOrder copyWith({ + Object? id = _Undefined, + String? deviceId, + _i1.UuidValue? targetUserId, + _i1.UuidValue? requestedBy, + _i2.DeviceWipeReason? reason, + DateTime? requestedAt, + Object? deliveredAt = _Undefined, + Object? confirmedAt = _Undefined, + }) { + return DeviceWipeOrder( + id: id is _i1.UuidValue? ? id : this.id, + deviceId: deviceId ?? this.deviceId, + targetUserId: targetUserId ?? this.targetUserId, + requestedBy: requestedBy ?? this.requestedBy, + reason: reason ?? this.reason, + requestedAt: requestedAt ?? this.requestedAt, + deliveredAt: deliveredAt is DateTime? ? deliveredAt : this.deliveredAt, + confirmedAt: confirmedAt is DateTime? ? confirmedAt : this.confirmedAt, + ); + } +} + +class DeviceWipeOrderUpdateTable extends _i1.UpdateTable { + DeviceWipeOrderUpdateTable(super.table); + + _i1.ColumnValue deviceId(String value) => _i1.ColumnValue( + table.deviceId, + value, + ); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> targetUserId( + _i1.UuidValue value, + ) => _i1.ColumnValue( + table.targetUserId, + value, + ); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> requestedBy( + _i1.UuidValue value, + ) => _i1.ColumnValue( + table.requestedBy, + value, + ); + + _i1.ColumnValue<_i2.DeviceWipeReason, _i2.DeviceWipeReason> reason( + _i2.DeviceWipeReason value, + ) => _i1.ColumnValue( + table.reason, + value, + ); + + _i1.ColumnValue requestedAt(DateTime value) => + _i1.ColumnValue( + table.requestedAt, + value, + ); + + _i1.ColumnValue deliveredAt(DateTime? value) => + _i1.ColumnValue( + table.deliveredAt, + value, + ); + + _i1.ColumnValue confirmedAt(DateTime? value) => + _i1.ColumnValue( + table.confirmedAt, + value, + ); +} + +class DeviceWipeOrderTable extends _i1.Table<_i1.UuidValue?> { + DeviceWipeOrderTable({super.tableRelation}) + : super(tableName: 'device_wipe_orders') { + updateTable = DeviceWipeOrderUpdateTable(this); + deviceId = _i1.ColumnString( + 'deviceId', + this, + ); + targetUserId = _i1.ColumnUuid( + 'targetUserId', + this, + ); + requestedBy = _i1.ColumnUuid( + 'requestedBy', + this, + ); + reason = _i1.ColumnEnum( + 'reason', + this, + _i1.EnumSerialization.byName, + ); + requestedAt = _i1.ColumnDateTime( + 'requestedAt', + this, + ); + deliveredAt = _i1.ColumnDateTime( + 'deliveredAt', + this, + ); + confirmedAt = _i1.ColumnDateTime( + 'confirmedAt', + this, + ); + } + + late final DeviceWipeOrderUpdateTable updateTable; + + /// Instalação alvo (`acs_refresh_tokens.deviceId`). + late final _i1.ColumnString deviceId; + + /// O ACS dono do aparelho no momento do pedido. + late final _i1.ColumnUuid targetUserId; + + /// Quem pediu (coordenador ou administrador). + late final _i1.ColumnUuid requestedBy; + + late final _i1.ColumnEnum<_i2.DeviceWipeReason> reason; + + late final _i1.ColumnDateTime requestedAt; + + late final _i1.ColumnDateTime deliveredAt; + + late final _i1.ColumnDateTime confirmedAt; + + @override + List<_i1.Column> get columns => [ + id, + deviceId, + targetUserId, + requestedBy, + reason, + requestedAt, + deliveredAt, + confirmedAt, + ]; +} + +class DeviceWipeOrderInclude extends _i1.IncludeObject { + DeviceWipeOrderInclude._(); + + @override + Map get includes => {}; + + @override + _i1.Table<_i1.UuidValue?> get table => DeviceWipeOrder.t; +} + +class DeviceWipeOrderIncludeList extends _i1.IncludeList { + DeviceWipeOrderIncludeList._({ + _i1.WhereExpressionBuilder? where, + super.limit, + super.offset, + super.orderBy, + super.orderDescending, + super.orderByList, + super.include, + }) { + super.where = where?.call(DeviceWipeOrder.t); + } + + @override + Map get includes => include?.includes ?? {}; + + @override + _i1.Table<_i1.UuidValue?> get table => DeviceWipeOrder.t; +} + +class DeviceWipeOrderRepository { + const DeviceWipeOrderRepository._(); + + /// Returns a list of [DeviceWipeOrder]s matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order of the items use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// The maximum number of items can be set by [limit]. If no limit is set, + /// all items matching the query will be returned. + /// + /// [offset] defines how many items to skip, after which [limit] (or all) + /// items are read from the database. + /// + /// ```dart + /// var persons = await Persons.db.find( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.firstName, + /// limit: 100, + /// ); + /// ``` + Future> find( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.find( + where: where?.call(DeviceWipeOrder.t), + orderBy: orderBy?.call(DeviceWipeOrder.t), + orderByList: orderByList?.call(DeviceWipeOrder.t), + orderDescending: orderDescending, + limit: limit, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Returns the first matching [DeviceWipeOrder] matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// [offset] defines how many items to skip, after which the next one will be picked. + /// + /// ```dart + /// var youngestPerson = await Persons.db.findFirstRow( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.age, + /// ); + /// ``` + Future findFirstRow( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findFirstRow( + where: where?.call(DeviceWipeOrder.t), + orderBy: orderBy?.call(DeviceWipeOrder.t), + orderByList: orderByList?.call(DeviceWipeOrder.t), + orderDescending: orderDescending, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Finds a single [DeviceWipeOrder] by its [id] or null if no such row exists. + Future findById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findById( + id, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Inserts all [DeviceWipeOrder]s in the list and returns the inserted rows. + /// + /// The returned [DeviceWipeOrder]s will have their `id` fields set. + /// + /// This is an atomic operation, meaning that if one of the rows fails to + /// insert, none of the rows will be inserted. + /// + /// If [ignoreConflicts] is set to `true`, rows that conflict with existing + /// rows are silently skipped, and only the successfully inserted rows are + /// returned. + Future> insert( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + bool ignoreConflicts = false, + }) async { + return session.db.insert( + rows, + transaction: transaction, + ignoreConflicts: ignoreConflicts, + ); + } + + /// Inserts a single [DeviceWipeOrder] and returns the inserted row. + /// + /// The returned [DeviceWipeOrder] will have its `id` field set. + Future insertRow( + _i1.DatabaseSession session, + DeviceWipeOrder row, { + _i1.Transaction? transaction, + }) async { + return session.db.insertRow( + row, + transaction: transaction, + ); + } + + /// Updates all [DeviceWipeOrder]s in the list and returns the updated rows. If + /// [columns] is provided, only those columns will be updated. Defaults to + /// all columns. + /// This is an atomic operation, meaning that if one of the rows fails to + /// update, none of the rows will be updated. + Future> update( + _i1.DatabaseSession session, + List rows, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.update( + rows, + columns: columns?.call(DeviceWipeOrder.t), + transaction: transaction, + ); + } + + /// Updates a single [DeviceWipeOrder]. The row needs to have its id set. + /// Optionally, a list of [columns] can be provided to only update those + /// columns. Defaults to all columns. + Future updateRow( + _i1.DatabaseSession session, + DeviceWipeOrder row, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.updateRow( + row, + columns: columns?.call(DeviceWipeOrder.t), + transaction: transaction, + ); + } + + /// Updates a single [DeviceWipeOrder] by its [id] with the specified [columnValues]. + /// Returns the updated row or null if no row with the given id exists. + Future updateById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + required _i1.ColumnValueListBuilder + columnValues, + _i1.Transaction? transaction, + }) async { + return session.db.updateById( + id, + columnValues: columnValues(DeviceWipeOrder.t.updateTable), + transaction: transaction, + ); + } + + /// Updates all [DeviceWipeOrder]s matching the [where] expression with the specified [columnValues]. + /// Returns the list of updated rows. + Future> updateWhere( + _i1.DatabaseSession session, { + required _i1.ColumnValueListBuilder + columnValues, + required _i1.WhereExpressionBuilder where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + _i1.OrderByListBuilder? orderByList, + bool orderDescending = false, + _i1.Transaction? transaction, + }) async { + return session.db.updateWhere( + columnValues: columnValues(DeviceWipeOrder.t.updateTable), + where: where(DeviceWipeOrder.t), + limit: limit, + offset: offset, + orderBy: orderBy?.call(DeviceWipeOrder.t), + orderByList: orderByList?.call(DeviceWipeOrder.t), + orderDescending: orderDescending, + transaction: transaction, + ); + } + + /// Deletes all [DeviceWipeOrder]s in the list and returns the deleted rows. + /// This is an atomic operation, meaning that if one of the rows fail to + /// be deleted, none of the rows will be deleted. + Future> delete( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + }) async { + return session.db.delete( + rows, + transaction: transaction, + ); + } + + /// Deletes a single [DeviceWipeOrder]. + Future deleteRow( + _i1.DatabaseSession session, + DeviceWipeOrder row, { + _i1.Transaction? transaction, + }) async { + return session.db.deleteRow( + row, + transaction: transaction, + ); + } + + /// Deletes all rows matching the [where] expression. + Future> deleteWhere( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + _i1.Transaction? transaction, + }) async { + return session.db.deleteWhere( + where: where(DeviceWipeOrder.t), + transaction: transaction, + ); + } + + /// Counts the number of rows matching the [where] expression. If omitted, + /// will return the count of all rows in the table. + Future count( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + _i1.Transaction? transaction, + }) async { + return session.db.count( + where: where?.call(DeviceWipeOrder.t), + limit: limit, + transaction: transaction, + ); + } + + /// Acquires row-level locks on [DeviceWipeOrder] rows matching the [where] expression. + Future lockRows( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + required _i1.LockMode lockMode, + required _i1.Transaction transaction, + _i1.LockBehavior lockBehavior = _i1.LockBehavior.wait, + }) async { + return session.db.lockRows( + where: where(DeviceWipeOrder.t), + lockMode: lockMode, + lockBehavior: lockBehavior, + transaction: transaction, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index 2f40b43..38e03ad 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -28,12 +28,14 @@ import 'package:sinalacs_server/src/generated/api/admin_audit_filter.dart' as _i14; import 'package:sinalacs_server/src/generated/enums/data_subject_request_status.dart' as _i15; -import 'package:sinalacs_server/src/generated/enums/escalation_reason.dart' +import 'package:sinalacs_server/src/generated/enums/device_wipe_reason.dart' as _i16; -import 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' +import 'package:sinalacs_server/src/generated/enums/escalation_reason.dart' as _i17; -import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' +import 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' as _i18; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i19; class Endpoints extends _i1.EndpointDispatch { @override @@ -661,6 +663,67 @@ class Endpoints extends _i1.EndpointDispatch { staffId: params['staffId'], ), ), + 'acsDevices': _i1.MethodConnector( + name: 'acsDevices', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'acsId': _i1.ParameterDescription( + name: 'acsId', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint).acsDevices( + session, + accessToken: params['accessToken'], + acsId: params['acsId'], + ), + ), + 'orderDeviceWipe': _i1.MethodConnector( + name: 'orderDeviceWipe', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'acsId': _i1.ParameterDescription( + name: 'acsId', + type: _i1.getType(), + nullable: false, + ), + 'deviceId': _i1.ParameterDescription( + name: 'deviceId', + type: _i1.getType(), + nullable: false, + ), + 'reason': _i1.ParameterDescription( + name: 'reason', + type: _i1.getType<_i16.DeviceWipeReason>(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).orderDeviceWipe( + session, + accessToken: params['accessToken'], + acsId: params['acsId'], + deviceId: params['deviceId'], + reason: params['reason'], + ), + ), 'escalateAlert': _i1.MethodConnector( name: 'escalateAlert', params: { @@ -676,7 +739,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'reason': _i1.ParameterDescription( name: 'reason', - type: _i1.getType<_i16.EscalationReason>(), + type: _i1.getType<_i17.EscalationReason>(), nullable: false, ), }, @@ -1617,7 +1680,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'purpose': _i1.ParameterDescription( name: 'purpose', - type: _i1.getType<_i17.ConsentPurpose>(), + type: _i1.getType<_i18.ConsentPurpose>(), nullable: false, ), 'granted': _i1.ParameterDescription( @@ -1686,7 +1749,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'purpose': _i1.ParameterDescription( name: 'purpose', - type: _i1.getType<_i17.ConsentPurpose>(), + type: _i1.getType<_i18.ConsentPurpose>(), nullable: false, ), }, @@ -1864,7 +1927,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'visits': _i1.ParameterDescription( name: 'visits', - type: _i1.getType>(), + type: _i1.getType>(), nullable: false, ), }, @@ -1888,7 +1951,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'visit': _i1.ParameterDescription( name: 'visit', - type: _i1.getType<_i18.VisitSyncEntry>(), + type: _i1.getType<_i19.VisitSyncEntry>(), nullable: false, ), 'expectedServerVersion': _i1.ParameterDescription( @@ -1924,7 +1987,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'visits': _i1.ParameterDescription( name: 'visits', - type: _i1.getType>(), + type: _i1.getType>(), nullable: false, ), }, @@ -1955,7 +2018,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'visits': _i1.ParameterDescription( name: 'visits', - type: _i1.getType>(), + type: _i1.getType>(), nullable: false, ), }, diff --git a/backend/sinalacs_server/lib/src/generated/enums/device_wipe_reason.dart b/backend/sinalacs_server/lib/src/generated/enums/device_wipe_reason.dart new file mode 100644 index 0000000..004865d --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/enums/device_wipe_reason.dart @@ -0,0 +1,44 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod/serverpod.dart' as _i1; + +/// Motivo da limpeza remota de um aparelho (#81). Conjunto fechado, sem texto +/// livre: o motivo vai para a trilha de auditoria e texto livre ali seria um +/// canal para dado pessoal. +enum DeviceWipeReason implements _i1.SerializableModel { + lost, + stolen, + replaced; + + static DeviceWipeReason fromJson(String name) { + switch (name) { + case 'lost': + return DeviceWipeReason.lost; + case 'stolen': + return DeviceWipeReason.stolen; + case 'replaced': + return DeviceWipeReason.replaced; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "DeviceWipeReason"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_server/lib/src/generated/enums/device_wipe_state.dart b/backend/sinalacs_server/lib/src/generated/enums/device_wipe_state.dart new file mode 100644 index 0000000..399c11d --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/enums/device_wipe_state.dart @@ -0,0 +1,44 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod/serverpod.dart' as _i1; + +/// Estado do pedido de limpeza de um aparelho (#81), derivado dos carimbos do +/// pedido: `pending` (pedido, aparelho ainda não mandado limpar), `delivered` +/// (ordem entregue ao aparelho) e `confirmed` (aparelho confirmou a limpeza). +enum DeviceWipeState implements _i1.SerializableModel { + pending, + delivered, + confirmed; + + static DeviceWipeState fromJson(String name) { + switch (name) { + case 'pending': + return DeviceWipeState.pending; + case 'delivered': + return DeviceWipeState.delivered; + case 'confirmed': + return DeviceWipeState.confirmed; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "DeviceWipeState"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_server/lib/src/generated/protocol.dart b/backend/sinalacs_server/lib/src/generated/protocol.dart index 3ea6fc7..c8e5c2d 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.dart +++ b/backend/sinalacs_server/lib/src/generated/protocol.dart @@ -23,105 +23,112 @@ import 'alert_outbox_entry.dart' as _i9; import 'api/acs_open_alert.dart' as _i10; import 'api/admin_acs.dart' as _i11; import 'api/admin_acs_creation_result.dart' as _i12; -import 'api/admin_alert.dart' as _i13; -import 'api/admin_alert_page.dart' as _i14; -import 'api/admin_alerts_pulse.dart' as _i15; -import 'api/admin_audit_chain_status.dart' as _i16; -import 'api/admin_audit_entry.dart' as _i17; -import 'api/admin_audit_export.dart' as _i18; -import 'api/admin_audit_filter.dart' as _i19; -import 'api/admin_audit_page.dart' as _i20; -import 'api/admin_data_subject_request.dart' as _i21; -import 'api/admin_data_subject_request_detail.dart' as _i22; -import 'api/admin_data_subject_request_page.dart' as _i23; -import 'api/admin_escalation_result.dart' as _i24; -import 'api/admin_indicators.dart' as _i25; -import 'api/admin_micro_area.dart' as _i26; -import 'api/admin_password_reset_result.dart' as _i27; -import 'api/admin_staff.dart' as _i28; -import 'api/admin_staff_mfa_reset_result.dart' as _i29; -import 'api/alert_ack_result.dart' as _i30; -import 'api/alert_resolve_result.dart' as _i31; -import 'api/alert_status_result.dart' as _i32; -import 'api/development_login_result.dart' as _i33; -import 'api/enrollment_result.dart' as _i34; -import 'api/enrollment_token_result.dart' as _i35; -import 'api/micro_area_patient.dart' as _i36; -import 'api/notice_history_entry.dart' as _i37; -import 'api/notice_history_page.dart' as _i38; -import 'api/notice_send_result.dart' as _i39; -import 'api/patient_consent_record.dart' as _i40; -import 'api/patient_data_overview.dart' as _i41; -import 'api/patient_data_subject_request_record.dart' as _i42; -import 'api/patient_risk_event.dart' as _i43; -import 'api/red_alert_result.dart' as _i44; -import 'api/service_health.dart' as _i45; -import 'api/terms_change_notice.dart' as _i46; -import 'api/terms_version_entry.dart' as _i47; -import 'api/totp_enrollment_start.dart' as _i48; -import 'api/triage_result.dart' as _i49; -import 'api/ubs_contact.dart' as _i50; -import 'api/visit_sync_entry.dart' as _i51; -import 'api/visit_sync_result.dart' as _i52; -import 'audit_chain_checkpoint.dart' as _i53; -import 'audit_export.dart' as _i54; -import 'audit_log.dart' as _i55; -import 'consent_log.dart' as _i56; -import 'data_subject_request.dart' as _i57; -import 'enrollment_token.dart' as _i58; -import 'enums/alert_status.dart' as _i59; -import 'enums/arrival_method.dart' as _i60; -import 'enums/audit_chain_reason.dart' as _i61; -import 'enums/audit_integrity_state.dart' as _i62; -import 'enums/audit_row_seal.dart' as _i63; -import 'enums/consent_purpose.dart' as _i64; -import 'enums/data_subject_request_status.dart' as _i65; -import 'enums/data_subject_request_type.dart' as _i66; -import 'enums/escalation_reason.dart' as _i67; -import 'enums/risk_level.dart' as _i68; -import 'enums/scheduled_notice_status.dart' as _i69; -import 'enums/sync_status.dart' as _i70; -import 'enums/user_role.dart' as _i71; -import 'enums/visit_authorship.dart' as _i72; -import 'exceptions/admin_invalid_request_exception.dart' as _i73; -import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i74; -import 'exceptions/alert_permission_exception.dart' as _i75; -import 'exceptions/alert_validation_exception.dart' as _i76; -import 'exceptions/authentication_failed_exception.dart' as _i77; -import 'exceptions/data_rights_exception.dart' as _i78; -import 'exceptions/endpoint_disabled_exception.dart' as _i79; -import 'exceptions/enrollment_exception.dart' as _i80; -import 'exceptions/mfa_enrollment_required_exception.dart' as _i81; -import 'exceptions/mfa_required_exception.dart' as _i82; -import 'exceptions/notice_delivery_exception.dart' as _i83; -import 'exceptions/otp_request_exception.dart' as _i84; -import 'exceptions/session_expired_exception.dart' as _i85; -import 'micro_area.dart' as _i86; -import 'otp_challenge.dart' as _i87; -import 'patient.dart' as _i88; -import 'push_token.dart' as _i89; -import 'scheduled_notice.dart' as _i90; -import 'staff_account.dart' as _i91; -import 'terms_version.dart' as _i92; -import 'triage_answer.dart' as _i93; -import 'triage_session.dart' as _i94; -import 'ubs.dart' as _i95; -import 'user.dart' as _i96; -import 'user_credential.dart' as _i97; -import 'visit.dart' as _i98; +import 'api/admin_acs_device.dart' as _i13; +import 'api/admin_alert.dart' as _i14; +import 'api/admin_alert_page.dart' as _i15; +import 'api/admin_alerts_pulse.dart' as _i16; +import 'api/admin_audit_chain_status.dart' as _i17; +import 'api/admin_audit_entry.dart' as _i18; +import 'api/admin_audit_export.dart' as _i19; +import 'api/admin_audit_filter.dart' as _i20; +import 'api/admin_audit_page.dart' as _i21; +import 'api/admin_data_subject_request.dart' as _i22; +import 'api/admin_data_subject_request_detail.dart' as _i23; +import 'api/admin_data_subject_request_page.dart' as _i24; +import 'api/admin_device_wipe_result.dart' as _i25; +import 'api/admin_escalation_result.dart' as _i26; +import 'api/admin_indicators.dart' as _i27; +import 'api/admin_micro_area.dart' as _i28; +import 'api/admin_password_reset_result.dart' as _i29; +import 'api/admin_staff.dart' as _i30; +import 'api/admin_staff_mfa_reset_result.dart' as _i31; +import 'api/alert_ack_result.dart' as _i32; +import 'api/alert_resolve_result.dart' as _i33; +import 'api/alert_status_result.dart' as _i34; +import 'api/development_login_result.dart' as _i35; +import 'api/enrollment_result.dart' as _i36; +import 'api/enrollment_token_result.dart' as _i37; +import 'api/micro_area_patient.dart' as _i38; +import 'api/notice_history_entry.dart' as _i39; +import 'api/notice_history_page.dart' as _i40; +import 'api/notice_send_result.dart' as _i41; +import 'api/patient_consent_record.dart' as _i42; +import 'api/patient_data_overview.dart' as _i43; +import 'api/patient_data_subject_request_record.dart' as _i44; +import 'api/patient_risk_event.dart' as _i45; +import 'api/red_alert_result.dart' as _i46; +import 'api/service_health.dart' as _i47; +import 'api/terms_change_notice.dart' as _i48; +import 'api/terms_version_entry.dart' as _i49; +import 'api/totp_enrollment_start.dart' as _i50; +import 'api/triage_result.dart' as _i51; +import 'api/ubs_contact.dart' as _i52; +import 'api/visit_sync_entry.dart' as _i53; +import 'api/visit_sync_result.dart' as _i54; +import 'audit_chain_checkpoint.dart' as _i55; +import 'audit_export.dart' as _i56; +import 'audit_log.dart' as _i57; +import 'consent_log.dart' as _i58; +import 'data_subject_request.dart' as _i59; +import 'device_wipe_order.dart' as _i60; +import 'enrollment_token.dart' as _i61; +import 'enums/alert_status.dart' as _i62; +import 'enums/arrival_method.dart' as _i63; +import 'enums/audit_chain_reason.dart' as _i64; +import 'enums/audit_integrity_state.dart' as _i65; +import 'enums/audit_row_seal.dart' as _i66; +import 'enums/consent_purpose.dart' as _i67; +import 'enums/data_subject_request_status.dart' as _i68; +import 'enums/data_subject_request_type.dart' as _i69; +import 'enums/device_wipe_reason.dart' as _i70; +import 'enums/device_wipe_state.dart' as _i71; +import 'enums/escalation_reason.dart' as _i72; +import 'enums/risk_level.dart' as _i73; +import 'enums/scheduled_notice_status.dart' as _i74; +import 'enums/sync_status.dart' as _i75; +import 'enums/user_role.dart' as _i76; +import 'enums/visit_authorship.dart' as _i77; +import 'exceptions/admin_invalid_request_exception.dart' as _i78; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i79; +import 'exceptions/alert_permission_exception.dart' as _i80; +import 'exceptions/alert_validation_exception.dart' as _i81; +import 'exceptions/authentication_failed_exception.dart' as _i82; +import 'exceptions/data_rights_exception.dart' as _i83; +import 'exceptions/endpoint_disabled_exception.dart' as _i84; +import 'exceptions/enrollment_exception.dart' as _i85; +import 'exceptions/mfa_enrollment_required_exception.dart' as _i86; +import 'exceptions/mfa_required_exception.dart' as _i87; +import 'exceptions/notice_delivery_exception.dart' as _i88; +import 'exceptions/otp_request_exception.dart' as _i89; +import 'exceptions/session_expired_exception.dart' as _i90; +import 'micro_area.dart' as _i91; +import 'otp_challenge.dart' as _i92; +import 'patient.dart' as _i93; +import 'push_token.dart' as _i94; +import 'scheduled_notice.dart' as _i95; +import 'staff_account.dart' as _i96; +import 'terms_version.dart' as _i97; +import 'triage_answer.dart' as _i98; +import 'triage_session.dart' as _i99; +import 'ubs.dart' as _i100; +import 'user.dart' as _i101; +import 'user_credential.dart' as _i102; +import 'visit.dart' as _i103; import 'package:sinalacs_server/src/generated/api/admin_micro_area.dart' - as _i99; -import 'package:sinalacs_server/src/generated/api/admin_acs.dart' as _i100; -import 'package:sinalacs_server/src/generated/api/admin_staff.dart' as _i101; + as _i104; +import 'package:sinalacs_server/src/generated/api/admin_acs.dart' as _i105; +import 'package:sinalacs_server/src/generated/api/admin_staff.dart' as _i106; +import 'package:sinalacs_server/src/generated/api/admin_acs_device.dart' + as _i107; import 'package:sinalacs_server/src/generated/api/terms_version_entry.dart' - as _i102; -import 'package:sinalacs_server/src/generated/api/acs_open_alert.dart' as _i103; + as _i108; +import 'package:sinalacs_server/src/generated/api/acs_open_alert.dart' as _i109; import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' - as _i104; + as _i110; import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' - as _i105; + as _i111; import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' - as _i106; + as _i112; export 'acs.dart'; export 'acs_refresh_token.dart'; export 'acs_upload_token.dart'; @@ -132,6 +139,7 @@ export 'alert_outbox_entry.dart'; export 'api/acs_open_alert.dart'; export 'api/admin_acs.dart'; export 'api/admin_acs_creation_result.dart'; +export 'api/admin_acs_device.dart'; export 'api/admin_alert.dart'; export 'api/admin_alert_page.dart'; export 'api/admin_alerts_pulse.dart'; @@ -143,6 +151,7 @@ export 'api/admin_audit_page.dart'; export 'api/admin_data_subject_request.dart'; export 'api/admin_data_subject_request_detail.dart'; export 'api/admin_data_subject_request_page.dart'; +export 'api/admin_device_wipe_result.dart'; export 'api/admin_escalation_result.dart'; export 'api/admin_indicators.dart'; export 'api/admin_micro_area.dart'; @@ -177,6 +186,7 @@ export 'audit_export.dart'; export 'audit_log.dart'; export 'consent_log.dart'; export 'data_subject_request.dart'; +export 'device_wipe_order.dart'; export 'enrollment_token.dart'; export 'enums/alert_status.dart'; export 'enums/arrival_method.dart'; @@ -186,6 +196,8 @@ export 'enums/audit_row_seal.dart'; export 'enums/consent_purpose.dart'; export 'enums/data_subject_request_status.dart'; export 'enums/data_subject_request_type.dart'; +export 'enums/device_wipe_reason.dart'; +export 'enums/device_wipe_state.dart'; export 'enums/escalation_reason.dart'; export 'enums/risk_level.dart'; export 'enums/scheduled_notice_status.dart'; @@ -1570,6 +1582,114 @@ class Protocol extends _i1.SerializationManagerServer { ], managed: true, ), + _i2.TableDefinition( + name: 'device_wipe_orders', + dartName: 'DeviceWipeOrder', + schema: 'public', + module: 'sinalacs', + columns: [ + _i2.ColumnDefinition( + name: 'id', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue?', + columnDefault: 'gen_random_uuid()', + ), + _i2.ColumnDefinition( + name: 'deviceId', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'targetUserId', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', + ), + _i2.ColumnDefinition( + name: 'requestedBy', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', + ), + _i2.ColumnDefinition( + name: 'reason', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'protocol:DeviceWipeReason', + ), + _i2.ColumnDefinition( + name: 'requestedAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'deliveredAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: true, + dartType: 'DateTime?', + ), + _i2.ColumnDefinition( + name: 'confirmedAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: true, + dartType: 'DateTime?', + ), + ], + foreignKeys: [ + _i2.ForeignKeyDefinition( + constraintName: 'device_wipe_orders_fk_0', + columns: ['targetUserId'], + referenceTable: 'users', + referenceTableSchema: 'public', + referenceColumns: ['id'], + onUpdate: _i2.ForeignKeyAction.noAction, + onDelete: _i2.ForeignKeyAction.noAction, + matchType: null, + ), + _i2.ForeignKeyDefinition( + constraintName: 'device_wipe_orders_fk_1', + columns: ['requestedBy'], + referenceTable: 'users', + referenceTableSchema: 'public', + referenceColumns: ['id'], + onUpdate: _i2.ForeignKeyAction.noAction, + onDelete: _i2.ForeignKeyAction.noAction, + matchType: null, + ), + ], + indexes: [ + _i2.IndexDefinition( + indexName: 'device_wipe_orders_pkey', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'id', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: true, + ), + _i2.IndexDefinition( + indexName: 'device_wipe_orders_device_key', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'deviceId', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: false, + ), + ], + managed: true, + ), _i2.TableDefinition( name: 'enrollment_tokens', dartName: 'EnrollmentToken', @@ -3042,263 +3162,278 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i12.AdminAcsCreationResult) { return _i12.AdminAcsCreationResult.fromJson(data) as T; } - if (t == _i13.AdminAlert) { - return _i13.AdminAlert.fromJson(data) as T; + if (t == _i13.AdminAcsDevice) { + return _i13.AdminAcsDevice.fromJson(data) as T; + } + if (t == _i14.AdminAlert) { + return _i14.AdminAlert.fromJson(data) as T; + } + if (t == _i15.AdminAlertPage) { + return _i15.AdminAlertPage.fromJson(data) as T; + } + if (t == _i16.AdminAlertsPulse) { + return _i16.AdminAlertsPulse.fromJson(data) as T; } - if (t == _i14.AdminAlertPage) { - return _i14.AdminAlertPage.fromJson(data) as T; + if (t == _i17.AdminAuditChainStatus) { + return _i17.AdminAuditChainStatus.fromJson(data) as T; } - if (t == _i15.AdminAlertsPulse) { - return _i15.AdminAlertsPulse.fromJson(data) as T; + if (t == _i18.AdminAuditEntry) { + return _i18.AdminAuditEntry.fromJson(data) as T; } - if (t == _i16.AdminAuditChainStatus) { - return _i16.AdminAuditChainStatus.fromJson(data) as T; + if (t == _i19.AdminAuditExport) { + return _i19.AdminAuditExport.fromJson(data) as T; } - if (t == _i17.AdminAuditEntry) { - return _i17.AdminAuditEntry.fromJson(data) as T; + if (t == _i20.AdminAuditFilter) { + return _i20.AdminAuditFilter.fromJson(data) as T; } - if (t == _i18.AdminAuditExport) { - return _i18.AdminAuditExport.fromJson(data) as T; + if (t == _i21.AdminAuditPage) { + return _i21.AdminAuditPage.fromJson(data) as T; } - if (t == _i19.AdminAuditFilter) { - return _i19.AdminAuditFilter.fromJson(data) as T; + if (t == _i22.AdminDataSubjectRequest) { + return _i22.AdminDataSubjectRequest.fromJson(data) as T; } - if (t == _i20.AdminAuditPage) { - return _i20.AdminAuditPage.fromJson(data) as T; + if (t == _i23.AdminDataSubjectRequestDetail) { + return _i23.AdminDataSubjectRequestDetail.fromJson(data) as T; } - if (t == _i21.AdminDataSubjectRequest) { - return _i21.AdminDataSubjectRequest.fromJson(data) as T; + if (t == _i24.AdminDataSubjectRequestPage) { + return _i24.AdminDataSubjectRequestPage.fromJson(data) as T; } - if (t == _i22.AdminDataSubjectRequestDetail) { - return _i22.AdminDataSubjectRequestDetail.fromJson(data) as T; + if (t == _i25.AdminDeviceWipeResult) { + return _i25.AdminDeviceWipeResult.fromJson(data) as T; } - if (t == _i23.AdminDataSubjectRequestPage) { - return _i23.AdminDataSubjectRequestPage.fromJson(data) as T; + if (t == _i26.AdminEscalationResult) { + return _i26.AdminEscalationResult.fromJson(data) as T; } - if (t == _i24.AdminEscalationResult) { - return _i24.AdminEscalationResult.fromJson(data) as T; + if (t == _i27.AdminIndicators) { + return _i27.AdminIndicators.fromJson(data) as T; } - if (t == _i25.AdminIndicators) { - return _i25.AdminIndicators.fromJson(data) as T; + if (t == _i28.AdminMicroArea) { + return _i28.AdminMicroArea.fromJson(data) as T; } - if (t == _i26.AdminMicroArea) { - return _i26.AdminMicroArea.fromJson(data) as T; + if (t == _i29.AdminPasswordResetResult) { + return _i29.AdminPasswordResetResult.fromJson(data) as T; } - if (t == _i27.AdminPasswordResetResult) { - return _i27.AdminPasswordResetResult.fromJson(data) as T; + if (t == _i30.AdminStaff) { + return _i30.AdminStaff.fromJson(data) as T; } - if (t == _i28.AdminStaff) { - return _i28.AdminStaff.fromJson(data) as T; + if (t == _i31.AdminStaffMfaResetResult) { + return _i31.AdminStaffMfaResetResult.fromJson(data) as T; } - if (t == _i29.AdminStaffMfaResetResult) { - return _i29.AdminStaffMfaResetResult.fromJson(data) as T; + if (t == _i32.AlertAckResult) { + return _i32.AlertAckResult.fromJson(data) as T; } - if (t == _i30.AlertAckResult) { - return _i30.AlertAckResult.fromJson(data) as T; + if (t == _i33.AlertResolveResult) { + return _i33.AlertResolveResult.fromJson(data) as T; } - if (t == _i31.AlertResolveResult) { - return _i31.AlertResolveResult.fromJson(data) as T; + if (t == _i34.AlertStatusResult) { + return _i34.AlertStatusResult.fromJson(data) as T; } - if (t == _i32.AlertStatusResult) { - return _i32.AlertStatusResult.fromJson(data) as T; + if (t == _i35.DevelopmentLoginResult) { + return _i35.DevelopmentLoginResult.fromJson(data) as T; } - if (t == _i33.DevelopmentLoginResult) { - return _i33.DevelopmentLoginResult.fromJson(data) as T; + if (t == _i36.EnrollmentResult) { + return _i36.EnrollmentResult.fromJson(data) as T; } - if (t == _i34.EnrollmentResult) { - return _i34.EnrollmentResult.fromJson(data) as T; + if (t == _i37.EnrollmentTokenResult) { + return _i37.EnrollmentTokenResult.fromJson(data) as T; } - if (t == _i35.EnrollmentTokenResult) { - return _i35.EnrollmentTokenResult.fromJson(data) as T; + if (t == _i38.MicroAreaPatient) { + return _i38.MicroAreaPatient.fromJson(data) as T; } - if (t == _i36.MicroAreaPatient) { - return _i36.MicroAreaPatient.fromJson(data) as T; + if (t == _i39.NoticeHistoryEntry) { + return _i39.NoticeHistoryEntry.fromJson(data) as T; } - if (t == _i37.NoticeHistoryEntry) { - return _i37.NoticeHistoryEntry.fromJson(data) as T; + if (t == _i40.NoticeHistoryPage) { + return _i40.NoticeHistoryPage.fromJson(data) as T; } - if (t == _i38.NoticeHistoryPage) { - return _i38.NoticeHistoryPage.fromJson(data) as T; + if (t == _i41.NoticeSendResult) { + return _i41.NoticeSendResult.fromJson(data) as T; } - if (t == _i39.NoticeSendResult) { - return _i39.NoticeSendResult.fromJson(data) as T; + if (t == _i42.PatientConsentRecord) { + return _i42.PatientConsentRecord.fromJson(data) as T; } - if (t == _i40.PatientConsentRecord) { - return _i40.PatientConsentRecord.fromJson(data) as T; + if (t == _i43.PatientDataOverview) { + return _i43.PatientDataOverview.fromJson(data) as T; } - if (t == _i41.PatientDataOverview) { - return _i41.PatientDataOverview.fromJson(data) as T; + if (t == _i44.PatientDataSubjectRequestRecord) { + return _i44.PatientDataSubjectRequestRecord.fromJson(data) as T; } - if (t == _i42.PatientDataSubjectRequestRecord) { - return _i42.PatientDataSubjectRequestRecord.fromJson(data) as T; + if (t == _i45.PatientRiskEvent) { + return _i45.PatientRiskEvent.fromJson(data) as T; } - if (t == _i43.PatientRiskEvent) { - return _i43.PatientRiskEvent.fromJson(data) as T; + if (t == _i46.RedAlertResult) { + return _i46.RedAlertResult.fromJson(data) as T; } - if (t == _i44.RedAlertResult) { - return _i44.RedAlertResult.fromJson(data) as T; + if (t == _i47.ServiceHealth) { + return _i47.ServiceHealth.fromJson(data) as T; } - if (t == _i45.ServiceHealth) { - return _i45.ServiceHealth.fromJson(data) as T; + if (t == _i48.TermsChangeNotice) { + return _i48.TermsChangeNotice.fromJson(data) as T; } - if (t == _i46.TermsChangeNotice) { - return _i46.TermsChangeNotice.fromJson(data) as T; + if (t == _i49.TermsVersionEntry) { + return _i49.TermsVersionEntry.fromJson(data) as T; } - if (t == _i47.TermsVersionEntry) { - return _i47.TermsVersionEntry.fromJson(data) as T; + if (t == _i50.TotpEnrollmentStart) { + return _i50.TotpEnrollmentStart.fromJson(data) as T; } - if (t == _i48.TotpEnrollmentStart) { - return _i48.TotpEnrollmentStart.fromJson(data) as T; + if (t == _i51.TriageResult) { + return _i51.TriageResult.fromJson(data) as T; } - if (t == _i49.TriageResult) { - return _i49.TriageResult.fromJson(data) as T; + if (t == _i52.UbsContact) { + return _i52.UbsContact.fromJson(data) as T; } - if (t == _i50.UbsContact) { - return _i50.UbsContact.fromJson(data) as T; + if (t == _i53.VisitSyncEntry) { + return _i53.VisitSyncEntry.fromJson(data) as T; } - if (t == _i51.VisitSyncEntry) { - return _i51.VisitSyncEntry.fromJson(data) as T; + if (t == _i54.VisitSyncResult) { + return _i54.VisitSyncResult.fromJson(data) as T; } - if (t == _i52.VisitSyncResult) { - return _i52.VisitSyncResult.fromJson(data) as T; + if (t == _i55.AuditChainCheckpoint) { + return _i55.AuditChainCheckpoint.fromJson(data) as T; } - if (t == _i53.AuditChainCheckpoint) { - return _i53.AuditChainCheckpoint.fromJson(data) as T; + if (t == _i56.AuditExport) { + return _i56.AuditExport.fromJson(data) as T; } - if (t == _i54.AuditExport) { - return _i54.AuditExport.fromJson(data) as T; + if (t == _i57.AuditLog) { + return _i57.AuditLog.fromJson(data) as T; } - if (t == _i55.AuditLog) { - return _i55.AuditLog.fromJson(data) as T; + if (t == _i58.ConsentLog) { + return _i58.ConsentLog.fromJson(data) as T; } - if (t == _i56.ConsentLog) { - return _i56.ConsentLog.fromJson(data) as T; + if (t == _i59.DataSubjectRequest) { + return _i59.DataSubjectRequest.fromJson(data) as T; } - if (t == _i57.DataSubjectRequest) { - return _i57.DataSubjectRequest.fromJson(data) as T; + if (t == _i60.DeviceWipeOrder) { + return _i60.DeviceWipeOrder.fromJson(data) as T; } - if (t == _i58.EnrollmentToken) { - return _i58.EnrollmentToken.fromJson(data) as T; + if (t == _i61.EnrollmentToken) { + return _i61.EnrollmentToken.fromJson(data) as T; } - if (t == _i59.AlertStatus) { - return _i59.AlertStatus.fromJson(data) as T; + if (t == _i62.AlertStatus) { + return _i62.AlertStatus.fromJson(data) as T; } - if (t == _i60.ArrivalMethod) { - return _i60.ArrivalMethod.fromJson(data) as T; + if (t == _i63.ArrivalMethod) { + return _i63.ArrivalMethod.fromJson(data) as T; } - if (t == _i61.AuditChainReason) { - return _i61.AuditChainReason.fromJson(data) as T; + if (t == _i64.AuditChainReason) { + return _i64.AuditChainReason.fromJson(data) as T; } - if (t == _i62.AuditIntegrityState) { - return _i62.AuditIntegrityState.fromJson(data) as T; + if (t == _i65.AuditIntegrityState) { + return _i65.AuditIntegrityState.fromJson(data) as T; } - if (t == _i63.AuditRowSeal) { - return _i63.AuditRowSeal.fromJson(data) as T; + if (t == _i66.AuditRowSeal) { + return _i66.AuditRowSeal.fromJson(data) as T; } - if (t == _i64.ConsentPurpose) { - return _i64.ConsentPurpose.fromJson(data) as T; + if (t == _i67.ConsentPurpose) { + return _i67.ConsentPurpose.fromJson(data) as T; } - if (t == _i65.DataSubjectRequestStatus) { - return _i65.DataSubjectRequestStatus.fromJson(data) as T; + if (t == _i68.DataSubjectRequestStatus) { + return _i68.DataSubjectRequestStatus.fromJson(data) as T; } - if (t == _i66.DataSubjectRequestType) { - return _i66.DataSubjectRequestType.fromJson(data) as T; + if (t == _i69.DataSubjectRequestType) { + return _i69.DataSubjectRequestType.fromJson(data) as T; } - if (t == _i67.EscalationReason) { - return _i67.EscalationReason.fromJson(data) as T; + if (t == _i70.DeviceWipeReason) { + return _i70.DeviceWipeReason.fromJson(data) as T; } - if (t == _i68.RiskLevel) { - return _i68.RiskLevel.fromJson(data) as T; + if (t == _i71.DeviceWipeState) { + return _i71.DeviceWipeState.fromJson(data) as T; } - if (t == _i69.ScheduledNoticeStatus) { - return _i69.ScheduledNoticeStatus.fromJson(data) as T; + if (t == _i72.EscalationReason) { + return _i72.EscalationReason.fromJson(data) as T; } - if (t == _i70.SyncStatus) { - return _i70.SyncStatus.fromJson(data) as T; + if (t == _i73.RiskLevel) { + return _i73.RiskLevel.fromJson(data) as T; } - if (t == _i71.UserRole) { - return _i71.UserRole.fromJson(data) as T; + if (t == _i74.ScheduledNoticeStatus) { + return _i74.ScheduledNoticeStatus.fromJson(data) as T; } - if (t == _i72.VisitAuthorship) { - return _i72.VisitAuthorship.fromJson(data) as T; + if (t == _i75.SyncStatus) { + return _i75.SyncStatus.fromJson(data) as T; } - if (t == _i73.AdminInvalidRequestException) { - return _i73.AdminInvalidRequestException.fromJson(data) as T; + if (t == _i76.UserRole) { + return _i76.UserRole.fromJson(data) as T; } - if (t == _i74.AlertDispatchUnavailableException) { - return _i74.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i77.VisitAuthorship) { + return _i77.VisitAuthorship.fromJson(data) as T; } - if (t == _i75.AlertPermissionException) { - return _i75.AlertPermissionException.fromJson(data) as T; + if (t == _i78.AdminInvalidRequestException) { + return _i78.AdminInvalidRequestException.fromJson(data) as T; } - if (t == _i76.AlertValidationException) { - return _i76.AlertValidationException.fromJson(data) as T; + if (t == _i79.AlertDispatchUnavailableException) { + return _i79.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i77.AuthenticationFailedException) { - return _i77.AuthenticationFailedException.fromJson(data) as T; + if (t == _i80.AlertPermissionException) { + return _i80.AlertPermissionException.fromJson(data) as T; } - if (t == _i78.DataRightsException) { - return _i78.DataRightsException.fromJson(data) as T; + if (t == _i81.AlertValidationException) { + return _i81.AlertValidationException.fromJson(data) as T; } - if (t == _i79.EndpointDisabledException) { - return _i79.EndpointDisabledException.fromJson(data) as T; + if (t == _i82.AuthenticationFailedException) { + return _i82.AuthenticationFailedException.fromJson(data) as T; } - if (t == _i80.EnrollmentException) { - return _i80.EnrollmentException.fromJson(data) as T; + if (t == _i83.DataRightsException) { + return _i83.DataRightsException.fromJson(data) as T; } - if (t == _i81.MfaEnrollmentRequiredException) { - return _i81.MfaEnrollmentRequiredException.fromJson(data) as T; + if (t == _i84.EndpointDisabledException) { + return _i84.EndpointDisabledException.fromJson(data) as T; } - if (t == _i82.MfaRequiredException) { - return _i82.MfaRequiredException.fromJson(data) as T; + if (t == _i85.EnrollmentException) { + return _i85.EnrollmentException.fromJson(data) as T; } - if (t == _i83.NoticeDeliveryException) { - return _i83.NoticeDeliveryException.fromJson(data) as T; + if (t == _i86.MfaEnrollmentRequiredException) { + return _i86.MfaEnrollmentRequiredException.fromJson(data) as T; } - if (t == _i84.OtpRequestException) { - return _i84.OtpRequestException.fromJson(data) as T; + if (t == _i87.MfaRequiredException) { + return _i87.MfaRequiredException.fromJson(data) as T; } - if (t == _i85.SessionExpiredException) { - return _i85.SessionExpiredException.fromJson(data) as T; + if (t == _i88.NoticeDeliveryException) { + return _i88.NoticeDeliveryException.fromJson(data) as T; } - if (t == _i86.MicroArea) { - return _i86.MicroArea.fromJson(data) as T; + if (t == _i89.OtpRequestException) { + return _i89.OtpRequestException.fromJson(data) as T; } - if (t == _i87.OtpChallenge) { - return _i87.OtpChallenge.fromJson(data) as T; + if (t == _i90.SessionExpiredException) { + return _i90.SessionExpiredException.fromJson(data) as T; } - if (t == _i88.Patient) { - return _i88.Patient.fromJson(data) as T; + if (t == _i91.MicroArea) { + return _i91.MicroArea.fromJson(data) as T; } - if (t == _i89.PushToken) { - return _i89.PushToken.fromJson(data) as T; + if (t == _i92.OtpChallenge) { + return _i92.OtpChallenge.fromJson(data) as T; } - if (t == _i90.ScheduledNotice) { - return _i90.ScheduledNotice.fromJson(data) as T; + if (t == _i93.Patient) { + return _i93.Patient.fromJson(data) as T; } - if (t == _i91.StaffAccount) { - return _i91.StaffAccount.fromJson(data) as T; + if (t == _i94.PushToken) { + return _i94.PushToken.fromJson(data) as T; } - if (t == _i92.TermsVersion) { - return _i92.TermsVersion.fromJson(data) as T; + if (t == _i95.ScheduledNotice) { + return _i95.ScheduledNotice.fromJson(data) as T; } - if (t == _i93.TriageAnswer) { - return _i93.TriageAnswer.fromJson(data) as T; + if (t == _i96.StaffAccount) { + return _i96.StaffAccount.fromJson(data) as T; } - if (t == _i94.TriageSession) { - return _i94.TriageSession.fromJson(data) as T; + if (t == _i97.TermsVersion) { + return _i97.TermsVersion.fromJson(data) as T; } - if (t == _i95.Ubs) { - return _i95.Ubs.fromJson(data) as T; + if (t == _i98.TriageAnswer) { + return _i98.TriageAnswer.fromJson(data) as T; } - if (t == _i96.User) { - return _i96.User.fromJson(data) as T; + if (t == _i99.TriageSession) { + return _i99.TriageSession.fromJson(data) as T; } - if (t == _i97.UserCredential) { - return _i97.UserCredential.fromJson(data) as T; + if (t == _i100.Ubs) { + return _i100.Ubs.fromJson(data) as T; } - if (t == _i98.Visit) { - return _i98.Visit.fromJson(data) as T; + if (t == _i101.User) { + return _i101.User.fromJson(data) as T; + } + if (t == _i102.UserCredential) { + return _i102.UserCredential.fromJson(data) as T; + } + if (t == _i103.Visit) { + return _i103.Visit.fromJson(data) as T; } if (t == _i1.getType<_i3.Acs?>()) { return (data != null ? _i3.Acs.fromJson(data) : null) as T; @@ -3333,364 +3468,380 @@ class Protocol extends _i1.SerializationManagerServer { return (data != null ? _i12.AdminAcsCreationResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i13.AdminAlert?>()) { - return (data != null ? _i13.AdminAlert.fromJson(data) : null) as T; + if (t == _i1.getType<_i13.AdminAcsDevice?>()) { + return (data != null ? _i13.AdminAcsDevice.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i14.AdminAlert?>()) { + return (data != null ? _i14.AdminAlert.fromJson(data) : null) as T; } - if (t == _i1.getType<_i14.AdminAlertPage?>()) { - return (data != null ? _i14.AdminAlertPage.fromJson(data) : null) as T; + if (t == _i1.getType<_i15.AdminAlertPage?>()) { + return (data != null ? _i15.AdminAlertPage.fromJson(data) : null) as T; } - if (t == _i1.getType<_i15.AdminAlertsPulse?>()) { - return (data != null ? _i15.AdminAlertsPulse.fromJson(data) : null) as T; + if (t == _i1.getType<_i16.AdminAlertsPulse?>()) { + return (data != null ? _i16.AdminAlertsPulse.fromJson(data) : null) as T; } - if (t == _i1.getType<_i16.AdminAuditChainStatus?>()) { - return (data != null ? _i16.AdminAuditChainStatus.fromJson(data) : null) + if (t == _i1.getType<_i17.AdminAuditChainStatus?>()) { + return (data != null ? _i17.AdminAuditChainStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i17.AdminAuditEntry?>()) { - return (data != null ? _i17.AdminAuditEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i18.AdminAuditEntry?>()) { + return (data != null ? _i18.AdminAuditEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i18.AdminAuditExport?>()) { - return (data != null ? _i18.AdminAuditExport.fromJson(data) : null) as T; + if (t == _i1.getType<_i19.AdminAuditExport?>()) { + return (data != null ? _i19.AdminAuditExport.fromJson(data) : null) as T; } - if (t == _i1.getType<_i19.AdminAuditFilter?>()) { - return (data != null ? _i19.AdminAuditFilter.fromJson(data) : null) as T; + if (t == _i1.getType<_i20.AdminAuditFilter?>()) { + return (data != null ? _i20.AdminAuditFilter.fromJson(data) : null) as T; } - if (t == _i1.getType<_i20.AdminAuditPage?>()) { - return (data != null ? _i20.AdminAuditPage.fromJson(data) : null) as T; + if (t == _i1.getType<_i21.AdminAuditPage?>()) { + return (data != null ? _i21.AdminAuditPage.fromJson(data) : null) as T; } - if (t == _i1.getType<_i21.AdminDataSubjectRequest?>()) { - return (data != null ? _i21.AdminDataSubjectRequest.fromJson(data) : null) + if (t == _i1.getType<_i22.AdminDataSubjectRequest?>()) { + return (data != null ? _i22.AdminDataSubjectRequest.fromJson(data) : null) as T; } - if (t == _i1.getType<_i22.AdminDataSubjectRequestDetail?>()) { + if (t == _i1.getType<_i23.AdminDataSubjectRequestDetail?>()) { return (data != null - ? _i22.AdminDataSubjectRequestDetail.fromJson(data) + ? _i23.AdminDataSubjectRequestDetail.fromJson(data) : null) as T; } - if (t == _i1.getType<_i23.AdminDataSubjectRequestPage?>()) { + if (t == _i1.getType<_i24.AdminDataSubjectRequestPage?>()) { return (data != null - ? _i23.AdminDataSubjectRequestPage.fromJson(data) + ? _i24.AdminDataSubjectRequestPage.fromJson(data) : null) as T; } - if (t == _i1.getType<_i24.AdminEscalationResult?>()) { - return (data != null ? _i24.AdminEscalationResult.fromJson(data) : null) + if (t == _i1.getType<_i25.AdminDeviceWipeResult?>()) { + return (data != null ? _i25.AdminDeviceWipeResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i25.AdminIndicators?>()) { - return (data != null ? _i25.AdminIndicators.fromJson(data) : null) as T; + if (t == _i1.getType<_i26.AdminEscalationResult?>()) { + return (data != null ? _i26.AdminEscalationResult.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i27.AdminIndicators?>()) { + return (data != null ? _i27.AdminIndicators.fromJson(data) : null) as T; } - if (t == _i1.getType<_i26.AdminMicroArea?>()) { - return (data != null ? _i26.AdminMicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i28.AdminMicroArea?>()) { + return (data != null ? _i28.AdminMicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i27.AdminPasswordResetResult?>()) { + if (t == _i1.getType<_i29.AdminPasswordResetResult?>()) { return (data != null - ? _i27.AdminPasswordResetResult.fromJson(data) + ? _i29.AdminPasswordResetResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i28.AdminStaff?>()) { - return (data != null ? _i28.AdminStaff.fromJson(data) : null) as T; + if (t == _i1.getType<_i30.AdminStaff?>()) { + return (data != null ? _i30.AdminStaff.fromJson(data) : null) as T; } - if (t == _i1.getType<_i29.AdminStaffMfaResetResult?>()) { + if (t == _i1.getType<_i31.AdminStaffMfaResetResult?>()) { return (data != null - ? _i29.AdminStaffMfaResetResult.fromJson(data) + ? _i31.AdminStaffMfaResetResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i30.AlertAckResult?>()) { - return (data != null ? _i30.AlertAckResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i32.AlertAckResult?>()) { + return (data != null ? _i32.AlertAckResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i31.AlertResolveResult?>()) { - return (data != null ? _i31.AlertResolveResult.fromJson(data) : null) + if (t == _i1.getType<_i33.AlertResolveResult?>()) { + return (data != null ? _i33.AlertResolveResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i32.AlertStatusResult?>()) { - return (data != null ? _i32.AlertStatusResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i34.AlertStatusResult?>()) { + return (data != null ? _i34.AlertStatusResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i33.DevelopmentLoginResult?>()) { - return (data != null ? _i33.DevelopmentLoginResult.fromJson(data) : null) + if (t == _i1.getType<_i35.DevelopmentLoginResult?>()) { + return (data != null ? _i35.DevelopmentLoginResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i34.EnrollmentResult?>()) { - return (data != null ? _i34.EnrollmentResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i36.EnrollmentResult?>()) { + return (data != null ? _i36.EnrollmentResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i35.EnrollmentTokenResult?>()) { - return (data != null ? _i35.EnrollmentTokenResult.fromJson(data) : null) + if (t == _i1.getType<_i37.EnrollmentTokenResult?>()) { + return (data != null ? _i37.EnrollmentTokenResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i36.MicroAreaPatient?>()) { - return (data != null ? _i36.MicroAreaPatient.fromJson(data) : null) as T; + if (t == _i1.getType<_i38.MicroAreaPatient?>()) { + return (data != null ? _i38.MicroAreaPatient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i37.NoticeHistoryEntry?>()) { - return (data != null ? _i37.NoticeHistoryEntry.fromJson(data) : null) + if (t == _i1.getType<_i39.NoticeHistoryEntry?>()) { + return (data != null ? _i39.NoticeHistoryEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i38.NoticeHistoryPage?>()) { - return (data != null ? _i38.NoticeHistoryPage.fromJson(data) : null) as T; + if (t == _i1.getType<_i40.NoticeHistoryPage?>()) { + return (data != null ? _i40.NoticeHistoryPage.fromJson(data) : null) as T; } - if (t == _i1.getType<_i39.NoticeSendResult?>()) { - return (data != null ? _i39.NoticeSendResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i41.NoticeSendResult?>()) { + return (data != null ? _i41.NoticeSendResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i40.PatientConsentRecord?>()) { - return (data != null ? _i40.PatientConsentRecord.fromJson(data) : null) + if (t == _i1.getType<_i42.PatientConsentRecord?>()) { + return (data != null ? _i42.PatientConsentRecord.fromJson(data) : null) as T; } - if (t == _i1.getType<_i41.PatientDataOverview?>()) { - return (data != null ? _i41.PatientDataOverview.fromJson(data) : null) + if (t == _i1.getType<_i43.PatientDataOverview?>()) { + return (data != null ? _i43.PatientDataOverview.fromJson(data) : null) as T; } - if (t == _i1.getType<_i42.PatientDataSubjectRequestRecord?>()) { + if (t == _i1.getType<_i44.PatientDataSubjectRequestRecord?>()) { return (data != null - ? _i42.PatientDataSubjectRequestRecord.fromJson(data) + ? _i44.PatientDataSubjectRequestRecord.fromJson(data) : null) as T; } - if (t == _i1.getType<_i43.PatientRiskEvent?>()) { - return (data != null ? _i43.PatientRiskEvent.fromJson(data) : null) as T; + if (t == _i1.getType<_i45.PatientRiskEvent?>()) { + return (data != null ? _i45.PatientRiskEvent.fromJson(data) : null) as T; } - if (t == _i1.getType<_i44.RedAlertResult?>()) { - return (data != null ? _i44.RedAlertResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i46.RedAlertResult?>()) { + return (data != null ? _i46.RedAlertResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i45.ServiceHealth?>()) { - return (data != null ? _i45.ServiceHealth.fromJson(data) : null) as T; + if (t == _i1.getType<_i47.ServiceHealth?>()) { + return (data != null ? _i47.ServiceHealth.fromJson(data) : null) as T; } - if (t == _i1.getType<_i46.TermsChangeNotice?>()) { - return (data != null ? _i46.TermsChangeNotice.fromJson(data) : null) as T; + if (t == _i1.getType<_i48.TermsChangeNotice?>()) { + return (data != null ? _i48.TermsChangeNotice.fromJson(data) : null) as T; } - if (t == _i1.getType<_i47.TermsVersionEntry?>()) { - return (data != null ? _i47.TermsVersionEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i49.TermsVersionEntry?>()) { + return (data != null ? _i49.TermsVersionEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i48.TotpEnrollmentStart?>()) { - return (data != null ? _i48.TotpEnrollmentStart.fromJson(data) : null) + if (t == _i1.getType<_i50.TotpEnrollmentStart?>()) { + return (data != null ? _i50.TotpEnrollmentStart.fromJson(data) : null) as T; } - if (t == _i1.getType<_i49.TriageResult?>()) { - return (data != null ? _i49.TriageResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i51.TriageResult?>()) { + return (data != null ? _i51.TriageResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i50.UbsContact?>()) { - return (data != null ? _i50.UbsContact.fromJson(data) : null) as T; + if (t == _i1.getType<_i52.UbsContact?>()) { + return (data != null ? _i52.UbsContact.fromJson(data) : null) as T; } - if (t == _i1.getType<_i51.VisitSyncEntry?>()) { - return (data != null ? _i51.VisitSyncEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i53.VisitSyncEntry?>()) { + return (data != null ? _i53.VisitSyncEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i52.VisitSyncResult?>()) { - return (data != null ? _i52.VisitSyncResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i54.VisitSyncResult?>()) { + return (data != null ? _i54.VisitSyncResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i53.AuditChainCheckpoint?>()) { - return (data != null ? _i53.AuditChainCheckpoint.fromJson(data) : null) + if (t == _i1.getType<_i55.AuditChainCheckpoint?>()) { + return (data != null ? _i55.AuditChainCheckpoint.fromJson(data) : null) as T; } - if (t == _i1.getType<_i54.AuditExport?>()) { - return (data != null ? _i54.AuditExport.fromJson(data) : null) as T; + if (t == _i1.getType<_i56.AuditExport?>()) { + return (data != null ? _i56.AuditExport.fromJson(data) : null) as T; } - if (t == _i1.getType<_i55.AuditLog?>()) { - return (data != null ? _i55.AuditLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i57.AuditLog?>()) { + return (data != null ? _i57.AuditLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i56.ConsentLog?>()) { - return (data != null ? _i56.ConsentLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i58.ConsentLog?>()) { + return (data != null ? _i58.ConsentLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i57.DataSubjectRequest?>()) { - return (data != null ? _i57.DataSubjectRequest.fromJson(data) : null) + if (t == _i1.getType<_i59.DataSubjectRequest?>()) { + return (data != null ? _i59.DataSubjectRequest.fromJson(data) : null) as T; } - if (t == _i1.getType<_i58.EnrollmentToken?>()) { - return (data != null ? _i58.EnrollmentToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i60.DeviceWipeOrder?>()) { + return (data != null ? _i60.DeviceWipeOrder.fromJson(data) : null) as T; } - if (t == _i1.getType<_i59.AlertStatus?>()) { - return (data != null ? _i59.AlertStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i61.EnrollmentToken?>()) { + return (data != null ? _i61.EnrollmentToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i60.ArrivalMethod?>()) { - return (data != null ? _i60.ArrivalMethod.fromJson(data) : null) as T; + if (t == _i1.getType<_i62.AlertStatus?>()) { + return (data != null ? _i62.AlertStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i61.AuditChainReason?>()) { - return (data != null ? _i61.AuditChainReason.fromJson(data) : null) as T; + if (t == _i1.getType<_i63.ArrivalMethod?>()) { + return (data != null ? _i63.ArrivalMethod.fromJson(data) : null) as T; } - if (t == _i1.getType<_i62.AuditIntegrityState?>()) { - return (data != null ? _i62.AuditIntegrityState.fromJson(data) : null) + if (t == _i1.getType<_i64.AuditChainReason?>()) { + return (data != null ? _i64.AuditChainReason.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i65.AuditIntegrityState?>()) { + return (data != null ? _i65.AuditIntegrityState.fromJson(data) : null) as T; } - if (t == _i1.getType<_i63.AuditRowSeal?>()) { - return (data != null ? _i63.AuditRowSeal.fromJson(data) : null) as T; + if (t == _i1.getType<_i66.AuditRowSeal?>()) { + return (data != null ? _i66.AuditRowSeal.fromJson(data) : null) as T; } - if (t == _i1.getType<_i64.ConsentPurpose?>()) { - return (data != null ? _i64.ConsentPurpose.fromJson(data) : null) as T; + if (t == _i1.getType<_i67.ConsentPurpose?>()) { + return (data != null ? _i67.ConsentPurpose.fromJson(data) : null) as T; } - if (t == _i1.getType<_i65.DataSubjectRequestStatus?>()) { + if (t == _i1.getType<_i68.DataSubjectRequestStatus?>()) { return (data != null - ? _i65.DataSubjectRequestStatus.fromJson(data) + ? _i68.DataSubjectRequestStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i66.DataSubjectRequestType?>()) { - return (data != null ? _i66.DataSubjectRequestType.fromJson(data) : null) + if (t == _i1.getType<_i69.DataSubjectRequestType?>()) { + return (data != null ? _i69.DataSubjectRequestType.fromJson(data) : null) as T; } - if (t == _i1.getType<_i67.EscalationReason?>()) { - return (data != null ? _i67.EscalationReason.fromJson(data) : null) as T; + if (t == _i1.getType<_i70.DeviceWipeReason?>()) { + return (data != null ? _i70.DeviceWipeReason.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i71.DeviceWipeState?>()) { + return (data != null ? _i71.DeviceWipeState.fromJson(data) : null) as T; } - if (t == _i1.getType<_i68.RiskLevel?>()) { - return (data != null ? _i68.RiskLevel.fromJson(data) : null) as T; + if (t == _i1.getType<_i72.EscalationReason?>()) { + return (data != null ? _i72.EscalationReason.fromJson(data) : null) as T; } - if (t == _i1.getType<_i69.ScheduledNoticeStatus?>()) { - return (data != null ? _i69.ScheduledNoticeStatus.fromJson(data) : null) + if (t == _i1.getType<_i73.RiskLevel?>()) { + return (data != null ? _i73.RiskLevel.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i74.ScheduledNoticeStatus?>()) { + return (data != null ? _i74.ScheduledNoticeStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i70.SyncStatus?>()) { - return (data != null ? _i70.SyncStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i75.SyncStatus?>()) { + return (data != null ? _i75.SyncStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i71.UserRole?>()) { - return (data != null ? _i71.UserRole.fromJson(data) : null) as T; + if (t == _i1.getType<_i76.UserRole?>()) { + return (data != null ? _i76.UserRole.fromJson(data) : null) as T; } - if (t == _i1.getType<_i72.VisitAuthorship?>()) { - return (data != null ? _i72.VisitAuthorship.fromJson(data) : null) as T; + if (t == _i1.getType<_i77.VisitAuthorship?>()) { + return (data != null ? _i77.VisitAuthorship.fromJson(data) : null) as T; } - if (t == _i1.getType<_i73.AdminInvalidRequestException?>()) { + if (t == _i1.getType<_i78.AdminInvalidRequestException?>()) { return (data != null - ? _i73.AdminInvalidRequestException.fromJson(data) + ? _i78.AdminInvalidRequestException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i74.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i79.AlertDispatchUnavailableException?>()) { return (data != null - ? _i74.AlertDispatchUnavailableException.fromJson(data) + ? _i79.AlertDispatchUnavailableException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i75.AlertPermissionException?>()) { + if (t == _i1.getType<_i80.AlertPermissionException?>()) { return (data != null - ? _i75.AlertPermissionException.fromJson(data) + ? _i80.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i76.AlertValidationException?>()) { + if (t == _i1.getType<_i81.AlertValidationException?>()) { return (data != null - ? _i76.AlertValidationException.fromJson(data) + ? _i81.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i77.AuthenticationFailedException?>()) { + if (t == _i1.getType<_i82.AuthenticationFailedException?>()) { return (data != null - ? _i77.AuthenticationFailedException.fromJson(data) + ? _i82.AuthenticationFailedException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i78.DataRightsException?>()) { - return (data != null ? _i78.DataRightsException.fromJson(data) : null) + if (t == _i1.getType<_i83.DataRightsException?>()) { + return (data != null ? _i83.DataRightsException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i79.EndpointDisabledException?>()) { + if (t == _i1.getType<_i84.EndpointDisabledException?>()) { return (data != null - ? _i79.EndpointDisabledException.fromJson(data) + ? _i84.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i80.EnrollmentException?>()) { - return (data != null ? _i80.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i85.EnrollmentException?>()) { + return (data != null ? _i85.EnrollmentException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i81.MfaEnrollmentRequiredException?>()) { + if (t == _i1.getType<_i86.MfaEnrollmentRequiredException?>()) { return (data != null - ? _i81.MfaEnrollmentRequiredException.fromJson(data) + ? _i86.MfaEnrollmentRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i82.MfaRequiredException?>()) { - return (data != null ? _i82.MfaRequiredException.fromJson(data) : null) + if (t == _i1.getType<_i87.MfaRequiredException?>()) { + return (data != null ? _i87.MfaRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i83.NoticeDeliveryException?>()) { - return (data != null ? _i83.NoticeDeliveryException.fromJson(data) : null) + if (t == _i1.getType<_i88.NoticeDeliveryException?>()) { + return (data != null ? _i88.NoticeDeliveryException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i84.OtpRequestException?>()) { - return (data != null ? _i84.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i89.OtpRequestException?>()) { + return (data != null ? _i89.OtpRequestException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i85.SessionExpiredException?>()) { - return (data != null ? _i85.SessionExpiredException.fromJson(data) : null) + if (t == _i1.getType<_i90.SessionExpiredException?>()) { + return (data != null ? _i90.SessionExpiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i86.MicroArea?>()) { - return (data != null ? _i86.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i91.MicroArea?>()) { + return (data != null ? _i91.MicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i87.OtpChallenge?>()) { - return (data != null ? _i87.OtpChallenge.fromJson(data) : null) as T; + if (t == _i1.getType<_i92.OtpChallenge?>()) { + return (data != null ? _i92.OtpChallenge.fromJson(data) : null) as T; } - if (t == _i1.getType<_i88.Patient?>()) { - return (data != null ? _i88.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i93.Patient?>()) { + return (data != null ? _i93.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i89.PushToken?>()) { - return (data != null ? _i89.PushToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i94.PushToken?>()) { + return (data != null ? _i94.PushToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i90.ScheduledNotice?>()) { - return (data != null ? _i90.ScheduledNotice.fromJson(data) : null) as T; + if (t == _i1.getType<_i95.ScheduledNotice?>()) { + return (data != null ? _i95.ScheduledNotice.fromJson(data) : null) as T; } - if (t == _i1.getType<_i91.StaffAccount?>()) { - return (data != null ? _i91.StaffAccount.fromJson(data) : null) as T; + if (t == _i1.getType<_i96.StaffAccount?>()) { + return (data != null ? _i96.StaffAccount.fromJson(data) : null) as T; } - if (t == _i1.getType<_i92.TermsVersion?>()) { - return (data != null ? _i92.TermsVersion.fromJson(data) : null) as T; + if (t == _i1.getType<_i97.TermsVersion?>()) { + return (data != null ? _i97.TermsVersion.fromJson(data) : null) as T; } - if (t == _i1.getType<_i93.TriageAnswer?>()) { - return (data != null ? _i93.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i98.TriageAnswer?>()) { + return (data != null ? _i98.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i94.TriageSession?>()) { - return (data != null ? _i94.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i99.TriageSession?>()) { + return (data != null ? _i99.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i95.Ubs?>()) { - return (data != null ? _i95.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i100.Ubs?>()) { + return (data != null ? _i100.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i96.User?>()) { - return (data != null ? _i96.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i101.User?>()) { + return (data != null ? _i101.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i97.UserCredential?>()) { - return (data != null ? _i97.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i102.UserCredential?>()) { + return (data != null ? _i102.UserCredential.fromJson(data) : null) as T; } - if (t == _i1.getType<_i98.Visit?>()) { - return (data != null ? _i98.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i103.Visit?>()) { + return (data != null ? _i103.Visit.fromJson(data) : null) as T; } - if (t == List<_i13.AdminAlert>) { - return (data as List).map((e) => deserialize<_i13.AdminAlert>(e)).toList() + if (t == List<_i14.AdminAlert>) { + return (data as List).map((e) => deserialize<_i14.AdminAlert>(e)).toList() as T; } - if (t == List<_i17.AdminAuditEntry>) { + if (t == List<_i18.AdminAuditEntry>) { return (data as List) - .map((e) => deserialize<_i17.AdminAuditEntry>(e)) + .map((e) => deserialize<_i18.AdminAuditEntry>(e)) .toList() as T; } - if (t == List<_i21.AdminDataSubjectRequest>) { + if (t == List<_i22.AdminDataSubjectRequest>) { return (data as List) - .map((e) => deserialize<_i21.AdminDataSubjectRequest>(e)) + .map((e) => deserialize<_i22.AdminDataSubjectRequest>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i37.NoticeHistoryEntry>) { + if (t == List<_i39.NoticeHistoryEntry>) { return (data as List) - .map((e) => deserialize<_i37.NoticeHistoryEntry>(e)) + .map((e) => deserialize<_i39.NoticeHistoryEntry>(e)) .toList() as T; } - if (t == List<_i40.PatientConsentRecord>) { + if (t == List<_i42.PatientConsentRecord>) { return (data as List) - .map((e) => deserialize<_i40.PatientConsentRecord>(e)) + .map((e) => deserialize<_i42.PatientConsentRecord>(e)) .toList() as T; } - if (t == List<_i43.PatientRiskEvent>) { + if (t == List<_i45.PatientRiskEvent>) { return (data as List) - .map((e) => deserialize<_i43.PatientRiskEvent>(e)) + .map((e) => deserialize<_i45.PatientRiskEvent>(e)) .toList() as T; } - if (t == List<_i42.PatientDataSubjectRequestRecord>) { + if (t == List<_i44.PatientDataSubjectRequestRecord>) { return (data as List) - .map((e) => deserialize<_i42.PatientDataSubjectRequestRecord>(e)) + .map((e) => deserialize<_i44.PatientDataSubjectRequestRecord>(e)) .toList() as T; } @@ -3700,52 +3851,58 @@ class Protocol extends _i1.SerializationManagerServer { ) as T; } - if (t == List<_i99.AdminMicroArea>) { + if (t == List<_i104.AdminMicroArea>) { return (data as List) - .map((e) => deserialize<_i99.AdminMicroArea>(e)) + .map((e) => deserialize<_i104.AdminMicroArea>(e)) .toList() as T; } - if (t == List<_i100.AdminAcs>) { - return (data as List).map((e) => deserialize<_i100.AdminAcs>(e)).toList() + if (t == List<_i105.AdminAcs>) { + return (data as List).map((e) => deserialize<_i105.AdminAcs>(e)).toList() + as T; + } + if (t == List<_i106.AdminStaff>) { + return (data as List) + .map((e) => deserialize<_i106.AdminStaff>(e)) + .toList() as T; } - if (t == List<_i101.AdminStaff>) { + if (t == List<_i107.AdminAcsDevice>) { return (data as List) - .map((e) => deserialize<_i101.AdminStaff>(e)) + .map((e) => deserialize<_i107.AdminAcsDevice>(e)) .toList() as T; } - if (t == List<_i102.TermsVersionEntry>) { + if (t == List<_i108.TermsVersionEntry>) { return (data as List) - .map((e) => deserialize<_i102.TermsVersionEntry>(e)) + .map((e) => deserialize<_i108.TermsVersionEntry>(e)) .toList() as T; } - if (t == List<_i103.AcsOpenAlert>) { + if (t == List<_i109.AcsOpenAlert>) { return (data as List) - .map((e) => deserialize<_i103.AcsOpenAlert>(e)) + .map((e) => deserialize<_i109.AcsOpenAlert>(e)) .toList() as T; } - if (t == List<_i104.MicroAreaPatient>) { + if (t == List<_i110.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i104.MicroAreaPatient>(e)) + .map((e) => deserialize<_i110.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i105.VisitSyncResult>) { + if (t == List<_i111.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i105.VisitSyncResult>(e)) + .map((e) => deserialize<_i111.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i106.VisitSyncEntry>) { + if (t == List<_i112.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i106.VisitSyncEntry>(e)) + .map((e) => deserialize<_i112.VisitSyncEntry>(e)) .toList() as T; } @@ -3767,93 +3924,98 @@ class Protocol extends _i1.SerializationManagerServer { _i10.AcsOpenAlert => 'AcsOpenAlert', _i11.AdminAcs => 'AdminAcs', _i12.AdminAcsCreationResult => 'AdminAcsCreationResult', - _i13.AdminAlert => 'AdminAlert', - _i14.AdminAlertPage => 'AdminAlertPage', - _i15.AdminAlertsPulse => 'AdminAlertsPulse', - _i16.AdminAuditChainStatus => 'AdminAuditChainStatus', - _i17.AdminAuditEntry => 'AdminAuditEntry', - _i18.AdminAuditExport => 'AdminAuditExport', - _i19.AdminAuditFilter => 'AdminAuditFilter', - _i20.AdminAuditPage => 'AdminAuditPage', - _i21.AdminDataSubjectRequest => 'AdminDataSubjectRequest', - _i22.AdminDataSubjectRequestDetail => 'AdminDataSubjectRequestDetail', - _i23.AdminDataSubjectRequestPage => 'AdminDataSubjectRequestPage', - _i24.AdminEscalationResult => 'AdminEscalationResult', - _i25.AdminIndicators => 'AdminIndicators', - _i26.AdminMicroArea => 'AdminMicroArea', - _i27.AdminPasswordResetResult => 'AdminPasswordResetResult', - _i28.AdminStaff => 'AdminStaff', - _i29.AdminStaffMfaResetResult => 'AdminStaffMfaResetResult', - _i30.AlertAckResult => 'AlertAckResult', - _i31.AlertResolveResult => 'AlertResolveResult', - _i32.AlertStatusResult => 'AlertStatusResult', - _i33.DevelopmentLoginResult => 'DevelopmentLoginResult', - _i34.EnrollmentResult => 'EnrollmentResult', - _i35.EnrollmentTokenResult => 'EnrollmentTokenResult', - _i36.MicroAreaPatient => 'MicroAreaPatient', - _i37.NoticeHistoryEntry => 'NoticeHistoryEntry', - _i38.NoticeHistoryPage => 'NoticeHistoryPage', - _i39.NoticeSendResult => 'NoticeSendResult', - _i40.PatientConsentRecord => 'PatientConsentRecord', - _i41.PatientDataOverview => 'PatientDataOverview', - _i42.PatientDataSubjectRequestRecord => 'PatientDataSubjectRequestRecord', - _i43.PatientRiskEvent => 'PatientRiskEvent', - _i44.RedAlertResult => 'RedAlertResult', - _i45.ServiceHealth => 'ServiceHealth', - _i46.TermsChangeNotice => 'TermsChangeNotice', - _i47.TermsVersionEntry => 'TermsVersionEntry', - _i48.TotpEnrollmentStart => 'TotpEnrollmentStart', - _i49.TriageResult => 'TriageResult', - _i50.UbsContact => 'UbsContact', - _i51.VisitSyncEntry => 'VisitSyncEntry', - _i52.VisitSyncResult => 'VisitSyncResult', - _i53.AuditChainCheckpoint => 'AuditChainCheckpoint', - _i54.AuditExport => 'AuditExport', - _i55.AuditLog => 'AuditLog', - _i56.ConsentLog => 'ConsentLog', - _i57.DataSubjectRequest => 'DataSubjectRequest', - _i58.EnrollmentToken => 'EnrollmentToken', - _i59.AlertStatus => 'AlertStatus', - _i60.ArrivalMethod => 'ArrivalMethod', - _i61.AuditChainReason => 'AuditChainReason', - _i62.AuditIntegrityState => 'AuditIntegrityState', - _i63.AuditRowSeal => 'AuditRowSeal', - _i64.ConsentPurpose => 'ConsentPurpose', - _i65.DataSubjectRequestStatus => 'DataSubjectRequestStatus', - _i66.DataSubjectRequestType => 'DataSubjectRequestType', - _i67.EscalationReason => 'EscalationReason', - _i68.RiskLevel => 'RiskLevel', - _i69.ScheduledNoticeStatus => 'ScheduledNoticeStatus', - _i70.SyncStatus => 'SyncStatus', - _i71.UserRole => 'UserRole', - _i72.VisitAuthorship => 'VisitAuthorship', - _i73.AdminInvalidRequestException => 'AdminInvalidRequestException', - _i74.AlertDispatchUnavailableException => + _i13.AdminAcsDevice => 'AdminAcsDevice', + _i14.AdminAlert => 'AdminAlert', + _i15.AdminAlertPage => 'AdminAlertPage', + _i16.AdminAlertsPulse => 'AdminAlertsPulse', + _i17.AdminAuditChainStatus => 'AdminAuditChainStatus', + _i18.AdminAuditEntry => 'AdminAuditEntry', + _i19.AdminAuditExport => 'AdminAuditExport', + _i20.AdminAuditFilter => 'AdminAuditFilter', + _i21.AdminAuditPage => 'AdminAuditPage', + _i22.AdminDataSubjectRequest => 'AdminDataSubjectRequest', + _i23.AdminDataSubjectRequestDetail => 'AdminDataSubjectRequestDetail', + _i24.AdminDataSubjectRequestPage => 'AdminDataSubjectRequestPage', + _i25.AdminDeviceWipeResult => 'AdminDeviceWipeResult', + _i26.AdminEscalationResult => 'AdminEscalationResult', + _i27.AdminIndicators => 'AdminIndicators', + _i28.AdminMicroArea => 'AdminMicroArea', + _i29.AdminPasswordResetResult => 'AdminPasswordResetResult', + _i30.AdminStaff => 'AdminStaff', + _i31.AdminStaffMfaResetResult => 'AdminStaffMfaResetResult', + _i32.AlertAckResult => 'AlertAckResult', + _i33.AlertResolveResult => 'AlertResolveResult', + _i34.AlertStatusResult => 'AlertStatusResult', + _i35.DevelopmentLoginResult => 'DevelopmentLoginResult', + _i36.EnrollmentResult => 'EnrollmentResult', + _i37.EnrollmentTokenResult => 'EnrollmentTokenResult', + _i38.MicroAreaPatient => 'MicroAreaPatient', + _i39.NoticeHistoryEntry => 'NoticeHistoryEntry', + _i40.NoticeHistoryPage => 'NoticeHistoryPage', + _i41.NoticeSendResult => 'NoticeSendResult', + _i42.PatientConsentRecord => 'PatientConsentRecord', + _i43.PatientDataOverview => 'PatientDataOverview', + _i44.PatientDataSubjectRequestRecord => 'PatientDataSubjectRequestRecord', + _i45.PatientRiskEvent => 'PatientRiskEvent', + _i46.RedAlertResult => 'RedAlertResult', + _i47.ServiceHealth => 'ServiceHealth', + _i48.TermsChangeNotice => 'TermsChangeNotice', + _i49.TermsVersionEntry => 'TermsVersionEntry', + _i50.TotpEnrollmentStart => 'TotpEnrollmentStart', + _i51.TriageResult => 'TriageResult', + _i52.UbsContact => 'UbsContact', + _i53.VisitSyncEntry => 'VisitSyncEntry', + _i54.VisitSyncResult => 'VisitSyncResult', + _i55.AuditChainCheckpoint => 'AuditChainCheckpoint', + _i56.AuditExport => 'AuditExport', + _i57.AuditLog => 'AuditLog', + _i58.ConsentLog => 'ConsentLog', + _i59.DataSubjectRequest => 'DataSubjectRequest', + _i60.DeviceWipeOrder => 'DeviceWipeOrder', + _i61.EnrollmentToken => 'EnrollmentToken', + _i62.AlertStatus => 'AlertStatus', + _i63.ArrivalMethod => 'ArrivalMethod', + _i64.AuditChainReason => 'AuditChainReason', + _i65.AuditIntegrityState => 'AuditIntegrityState', + _i66.AuditRowSeal => 'AuditRowSeal', + _i67.ConsentPurpose => 'ConsentPurpose', + _i68.DataSubjectRequestStatus => 'DataSubjectRequestStatus', + _i69.DataSubjectRequestType => 'DataSubjectRequestType', + _i70.DeviceWipeReason => 'DeviceWipeReason', + _i71.DeviceWipeState => 'DeviceWipeState', + _i72.EscalationReason => 'EscalationReason', + _i73.RiskLevel => 'RiskLevel', + _i74.ScheduledNoticeStatus => 'ScheduledNoticeStatus', + _i75.SyncStatus => 'SyncStatus', + _i76.UserRole => 'UserRole', + _i77.VisitAuthorship => 'VisitAuthorship', + _i78.AdminInvalidRequestException => 'AdminInvalidRequestException', + _i79.AlertDispatchUnavailableException => 'AlertDispatchUnavailableException', - _i75.AlertPermissionException => 'AlertPermissionException', - _i76.AlertValidationException => 'AlertValidationException', - _i77.AuthenticationFailedException => 'AuthenticationFailedException', - _i78.DataRightsException => 'DataRightsException', - _i79.EndpointDisabledException => 'EndpointDisabledException', - _i80.EnrollmentException => 'EnrollmentException', - _i81.MfaEnrollmentRequiredException => 'MfaEnrollmentRequiredException', - _i82.MfaRequiredException => 'MfaRequiredException', - _i83.NoticeDeliveryException => 'NoticeDeliveryException', - _i84.OtpRequestException => 'OtpRequestException', - _i85.SessionExpiredException => 'SessionExpiredException', - _i86.MicroArea => 'MicroArea', - _i87.OtpChallenge => 'OtpChallenge', - _i88.Patient => 'Patient', - _i89.PushToken => 'PushToken', - _i90.ScheduledNotice => 'ScheduledNotice', - _i91.StaffAccount => 'StaffAccount', - _i92.TermsVersion => 'TermsVersion', - _i93.TriageAnswer => 'TriageAnswer', - _i94.TriageSession => 'TriageSession', - _i95.Ubs => 'Ubs', - _i96.User => 'User', - _i97.UserCredential => 'UserCredential', - _i98.Visit => 'Visit', + _i80.AlertPermissionException => 'AlertPermissionException', + _i81.AlertValidationException => 'AlertValidationException', + _i82.AuthenticationFailedException => 'AuthenticationFailedException', + _i83.DataRightsException => 'DataRightsException', + _i84.EndpointDisabledException => 'EndpointDisabledException', + _i85.EnrollmentException => 'EnrollmentException', + _i86.MfaEnrollmentRequiredException => 'MfaEnrollmentRequiredException', + _i87.MfaRequiredException => 'MfaRequiredException', + _i88.NoticeDeliveryException => 'NoticeDeliveryException', + _i89.OtpRequestException => 'OtpRequestException', + _i90.SessionExpiredException => 'SessionExpiredException', + _i91.MicroArea => 'MicroArea', + _i92.OtpChallenge => 'OtpChallenge', + _i93.Patient => 'Patient', + _i94.PushToken => 'PushToken', + _i95.ScheduledNotice => 'ScheduledNotice', + _i96.StaffAccount => 'StaffAccount', + _i97.TermsVersion => 'TermsVersion', + _i98.TriageAnswer => 'TriageAnswer', + _i99.TriageSession => 'TriageSession', + _i100.Ubs => 'Ubs', + _i101.User => 'User', + _i102.UserCredential => 'UserCredential', + _i103.Visit => 'Visit', _ => null, }; } @@ -3888,177 +4050,187 @@ class Protocol extends _i1.SerializationManagerServer { return 'AdminAcs'; case _i12.AdminAcsCreationResult(): return 'AdminAcsCreationResult'; - case _i13.AdminAlert(): + case _i13.AdminAcsDevice(): + return 'AdminAcsDevice'; + case _i14.AdminAlert(): return 'AdminAlert'; - case _i14.AdminAlertPage(): + case _i15.AdminAlertPage(): return 'AdminAlertPage'; - case _i15.AdminAlertsPulse(): + case _i16.AdminAlertsPulse(): return 'AdminAlertsPulse'; - case _i16.AdminAuditChainStatus(): + case _i17.AdminAuditChainStatus(): return 'AdminAuditChainStatus'; - case _i17.AdminAuditEntry(): + case _i18.AdminAuditEntry(): return 'AdminAuditEntry'; - case _i18.AdminAuditExport(): + case _i19.AdminAuditExport(): return 'AdminAuditExport'; - case _i19.AdminAuditFilter(): + case _i20.AdminAuditFilter(): return 'AdminAuditFilter'; - case _i20.AdminAuditPage(): + case _i21.AdminAuditPage(): return 'AdminAuditPage'; - case _i21.AdminDataSubjectRequest(): + case _i22.AdminDataSubjectRequest(): return 'AdminDataSubjectRequest'; - case _i22.AdminDataSubjectRequestDetail(): + case _i23.AdminDataSubjectRequestDetail(): return 'AdminDataSubjectRequestDetail'; - case _i23.AdminDataSubjectRequestPage(): + case _i24.AdminDataSubjectRequestPage(): return 'AdminDataSubjectRequestPage'; - case _i24.AdminEscalationResult(): + case _i25.AdminDeviceWipeResult(): + return 'AdminDeviceWipeResult'; + case _i26.AdminEscalationResult(): return 'AdminEscalationResult'; - case _i25.AdminIndicators(): + case _i27.AdminIndicators(): return 'AdminIndicators'; - case _i26.AdminMicroArea(): + case _i28.AdminMicroArea(): return 'AdminMicroArea'; - case _i27.AdminPasswordResetResult(): + case _i29.AdminPasswordResetResult(): return 'AdminPasswordResetResult'; - case _i28.AdminStaff(): + case _i30.AdminStaff(): return 'AdminStaff'; - case _i29.AdminStaffMfaResetResult(): + case _i31.AdminStaffMfaResetResult(): return 'AdminStaffMfaResetResult'; - case _i30.AlertAckResult(): + case _i32.AlertAckResult(): return 'AlertAckResult'; - case _i31.AlertResolveResult(): + case _i33.AlertResolveResult(): return 'AlertResolveResult'; - case _i32.AlertStatusResult(): + case _i34.AlertStatusResult(): return 'AlertStatusResult'; - case _i33.DevelopmentLoginResult(): + case _i35.DevelopmentLoginResult(): return 'DevelopmentLoginResult'; - case _i34.EnrollmentResult(): + case _i36.EnrollmentResult(): return 'EnrollmentResult'; - case _i35.EnrollmentTokenResult(): + case _i37.EnrollmentTokenResult(): return 'EnrollmentTokenResult'; - case _i36.MicroAreaPatient(): + case _i38.MicroAreaPatient(): return 'MicroAreaPatient'; - case _i37.NoticeHistoryEntry(): + case _i39.NoticeHistoryEntry(): return 'NoticeHistoryEntry'; - case _i38.NoticeHistoryPage(): + case _i40.NoticeHistoryPage(): return 'NoticeHistoryPage'; - case _i39.NoticeSendResult(): + case _i41.NoticeSendResult(): return 'NoticeSendResult'; - case _i40.PatientConsentRecord(): + case _i42.PatientConsentRecord(): return 'PatientConsentRecord'; - case _i41.PatientDataOverview(): + case _i43.PatientDataOverview(): return 'PatientDataOverview'; - case _i42.PatientDataSubjectRequestRecord(): + case _i44.PatientDataSubjectRequestRecord(): return 'PatientDataSubjectRequestRecord'; - case _i43.PatientRiskEvent(): + case _i45.PatientRiskEvent(): return 'PatientRiskEvent'; - case _i44.RedAlertResult(): + case _i46.RedAlertResult(): return 'RedAlertResult'; - case _i45.ServiceHealth(): + case _i47.ServiceHealth(): return 'ServiceHealth'; - case _i46.TermsChangeNotice(): + case _i48.TermsChangeNotice(): return 'TermsChangeNotice'; - case _i47.TermsVersionEntry(): + case _i49.TermsVersionEntry(): return 'TermsVersionEntry'; - case _i48.TotpEnrollmentStart(): + case _i50.TotpEnrollmentStart(): return 'TotpEnrollmentStart'; - case _i49.TriageResult(): + case _i51.TriageResult(): return 'TriageResult'; - case _i50.UbsContact(): + case _i52.UbsContact(): return 'UbsContact'; - case _i51.VisitSyncEntry(): + case _i53.VisitSyncEntry(): return 'VisitSyncEntry'; - case _i52.VisitSyncResult(): + case _i54.VisitSyncResult(): return 'VisitSyncResult'; - case _i53.AuditChainCheckpoint(): + case _i55.AuditChainCheckpoint(): return 'AuditChainCheckpoint'; - case _i54.AuditExport(): + case _i56.AuditExport(): return 'AuditExport'; - case _i55.AuditLog(): + case _i57.AuditLog(): return 'AuditLog'; - case _i56.ConsentLog(): + case _i58.ConsentLog(): return 'ConsentLog'; - case _i57.DataSubjectRequest(): + case _i59.DataSubjectRequest(): return 'DataSubjectRequest'; - case _i58.EnrollmentToken(): + case _i60.DeviceWipeOrder(): + return 'DeviceWipeOrder'; + case _i61.EnrollmentToken(): return 'EnrollmentToken'; - case _i59.AlertStatus(): + case _i62.AlertStatus(): return 'AlertStatus'; - case _i60.ArrivalMethod(): + case _i63.ArrivalMethod(): return 'ArrivalMethod'; - case _i61.AuditChainReason(): + case _i64.AuditChainReason(): return 'AuditChainReason'; - case _i62.AuditIntegrityState(): + case _i65.AuditIntegrityState(): return 'AuditIntegrityState'; - case _i63.AuditRowSeal(): + case _i66.AuditRowSeal(): return 'AuditRowSeal'; - case _i64.ConsentPurpose(): + case _i67.ConsentPurpose(): return 'ConsentPurpose'; - case _i65.DataSubjectRequestStatus(): + case _i68.DataSubjectRequestStatus(): return 'DataSubjectRequestStatus'; - case _i66.DataSubjectRequestType(): + case _i69.DataSubjectRequestType(): return 'DataSubjectRequestType'; - case _i67.EscalationReason(): + case _i70.DeviceWipeReason(): + return 'DeviceWipeReason'; + case _i71.DeviceWipeState(): + return 'DeviceWipeState'; + case _i72.EscalationReason(): return 'EscalationReason'; - case _i68.RiskLevel(): + case _i73.RiskLevel(): return 'RiskLevel'; - case _i69.ScheduledNoticeStatus(): + case _i74.ScheduledNoticeStatus(): return 'ScheduledNoticeStatus'; - case _i70.SyncStatus(): + case _i75.SyncStatus(): return 'SyncStatus'; - case _i71.UserRole(): + case _i76.UserRole(): return 'UserRole'; - case _i72.VisitAuthorship(): + case _i77.VisitAuthorship(): return 'VisitAuthorship'; - case _i73.AdminInvalidRequestException(): + case _i78.AdminInvalidRequestException(): return 'AdminInvalidRequestException'; - case _i74.AlertDispatchUnavailableException(): + case _i79.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i75.AlertPermissionException(): + case _i80.AlertPermissionException(): return 'AlertPermissionException'; - case _i76.AlertValidationException(): + case _i81.AlertValidationException(): return 'AlertValidationException'; - case _i77.AuthenticationFailedException(): + case _i82.AuthenticationFailedException(): return 'AuthenticationFailedException'; - case _i78.DataRightsException(): + case _i83.DataRightsException(): return 'DataRightsException'; - case _i79.EndpointDisabledException(): + case _i84.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i80.EnrollmentException(): + case _i85.EnrollmentException(): return 'EnrollmentException'; - case _i81.MfaEnrollmentRequiredException(): + case _i86.MfaEnrollmentRequiredException(): return 'MfaEnrollmentRequiredException'; - case _i82.MfaRequiredException(): + case _i87.MfaRequiredException(): return 'MfaRequiredException'; - case _i83.NoticeDeliveryException(): + case _i88.NoticeDeliveryException(): return 'NoticeDeliveryException'; - case _i84.OtpRequestException(): + case _i89.OtpRequestException(): return 'OtpRequestException'; - case _i85.SessionExpiredException(): + case _i90.SessionExpiredException(): return 'SessionExpiredException'; - case _i86.MicroArea(): + case _i91.MicroArea(): return 'MicroArea'; - case _i87.OtpChallenge(): + case _i92.OtpChallenge(): return 'OtpChallenge'; - case _i88.Patient(): + case _i93.Patient(): return 'Patient'; - case _i89.PushToken(): + case _i94.PushToken(): return 'PushToken'; - case _i90.ScheduledNotice(): + case _i95.ScheduledNotice(): return 'ScheduledNotice'; - case _i91.StaffAccount(): + case _i96.StaffAccount(): return 'StaffAccount'; - case _i92.TermsVersion(): + case _i97.TermsVersion(): return 'TermsVersion'; - case _i93.TriageAnswer(): + case _i98.TriageAnswer(): return 'TriageAnswer'; - case _i94.TriageSession(): + case _i99.TriageSession(): return 'TriageSession'; - case _i95.Ubs(): + case _i100.Ubs(): return 'Ubs'; - case _i96.User(): + case _i101.User(): return 'User'; - case _i97.UserCredential(): + case _i102.UserCredential(): return 'UserCredential'; - case _i98.Visit(): + case _i103.Visit(): return 'Visit'; } className = _i2.Protocol().getClassNameForObject(data); @@ -4104,263 +4276,278 @@ class Protocol extends _i1.SerializationManagerServer { if (dataClassName == 'AdminAcsCreationResult') { return deserialize<_i12.AdminAcsCreationResult>(data['data']); } + if (dataClassName == 'AdminAcsDevice') { + return deserialize<_i13.AdminAcsDevice>(data['data']); + } if (dataClassName == 'AdminAlert') { - return deserialize<_i13.AdminAlert>(data['data']); + return deserialize<_i14.AdminAlert>(data['data']); } if (dataClassName == 'AdminAlertPage') { - return deserialize<_i14.AdminAlertPage>(data['data']); + return deserialize<_i15.AdminAlertPage>(data['data']); } if (dataClassName == 'AdminAlertsPulse') { - return deserialize<_i15.AdminAlertsPulse>(data['data']); + return deserialize<_i16.AdminAlertsPulse>(data['data']); } if (dataClassName == 'AdminAuditChainStatus') { - return deserialize<_i16.AdminAuditChainStatus>(data['data']); + return deserialize<_i17.AdminAuditChainStatus>(data['data']); } if (dataClassName == 'AdminAuditEntry') { - return deserialize<_i17.AdminAuditEntry>(data['data']); + return deserialize<_i18.AdminAuditEntry>(data['data']); } if (dataClassName == 'AdminAuditExport') { - return deserialize<_i18.AdminAuditExport>(data['data']); + return deserialize<_i19.AdminAuditExport>(data['data']); } if (dataClassName == 'AdminAuditFilter') { - return deserialize<_i19.AdminAuditFilter>(data['data']); + return deserialize<_i20.AdminAuditFilter>(data['data']); } if (dataClassName == 'AdminAuditPage') { - return deserialize<_i20.AdminAuditPage>(data['data']); + return deserialize<_i21.AdminAuditPage>(data['data']); } if (dataClassName == 'AdminDataSubjectRequest') { - return deserialize<_i21.AdminDataSubjectRequest>(data['data']); + return deserialize<_i22.AdminDataSubjectRequest>(data['data']); } if (dataClassName == 'AdminDataSubjectRequestDetail') { - return deserialize<_i22.AdminDataSubjectRequestDetail>(data['data']); + return deserialize<_i23.AdminDataSubjectRequestDetail>(data['data']); } if (dataClassName == 'AdminDataSubjectRequestPage') { - return deserialize<_i23.AdminDataSubjectRequestPage>(data['data']); + return deserialize<_i24.AdminDataSubjectRequestPage>(data['data']); + } + if (dataClassName == 'AdminDeviceWipeResult') { + return deserialize<_i25.AdminDeviceWipeResult>(data['data']); } if (dataClassName == 'AdminEscalationResult') { - return deserialize<_i24.AdminEscalationResult>(data['data']); + return deserialize<_i26.AdminEscalationResult>(data['data']); } if (dataClassName == 'AdminIndicators') { - return deserialize<_i25.AdminIndicators>(data['data']); + return deserialize<_i27.AdminIndicators>(data['data']); } if (dataClassName == 'AdminMicroArea') { - return deserialize<_i26.AdminMicroArea>(data['data']); + return deserialize<_i28.AdminMicroArea>(data['data']); } if (dataClassName == 'AdminPasswordResetResult') { - return deserialize<_i27.AdminPasswordResetResult>(data['data']); + return deserialize<_i29.AdminPasswordResetResult>(data['data']); } if (dataClassName == 'AdminStaff') { - return deserialize<_i28.AdminStaff>(data['data']); + return deserialize<_i30.AdminStaff>(data['data']); } if (dataClassName == 'AdminStaffMfaResetResult') { - return deserialize<_i29.AdminStaffMfaResetResult>(data['data']); + return deserialize<_i31.AdminStaffMfaResetResult>(data['data']); } if (dataClassName == 'AlertAckResult') { - return deserialize<_i30.AlertAckResult>(data['data']); + return deserialize<_i32.AlertAckResult>(data['data']); } if (dataClassName == 'AlertResolveResult') { - return deserialize<_i31.AlertResolveResult>(data['data']); + return deserialize<_i33.AlertResolveResult>(data['data']); } if (dataClassName == 'AlertStatusResult') { - return deserialize<_i32.AlertStatusResult>(data['data']); + return deserialize<_i34.AlertStatusResult>(data['data']); } if (dataClassName == 'DevelopmentLoginResult') { - return deserialize<_i33.DevelopmentLoginResult>(data['data']); + return deserialize<_i35.DevelopmentLoginResult>(data['data']); } if (dataClassName == 'EnrollmentResult') { - return deserialize<_i34.EnrollmentResult>(data['data']); + return deserialize<_i36.EnrollmentResult>(data['data']); } if (dataClassName == 'EnrollmentTokenResult') { - return deserialize<_i35.EnrollmentTokenResult>(data['data']); + return deserialize<_i37.EnrollmentTokenResult>(data['data']); } if (dataClassName == 'MicroAreaPatient') { - return deserialize<_i36.MicroAreaPatient>(data['data']); + return deserialize<_i38.MicroAreaPatient>(data['data']); } if (dataClassName == 'NoticeHistoryEntry') { - return deserialize<_i37.NoticeHistoryEntry>(data['data']); + return deserialize<_i39.NoticeHistoryEntry>(data['data']); } if (dataClassName == 'NoticeHistoryPage') { - return deserialize<_i38.NoticeHistoryPage>(data['data']); + return deserialize<_i40.NoticeHistoryPage>(data['data']); } if (dataClassName == 'NoticeSendResult') { - return deserialize<_i39.NoticeSendResult>(data['data']); + return deserialize<_i41.NoticeSendResult>(data['data']); } if (dataClassName == 'PatientConsentRecord') { - return deserialize<_i40.PatientConsentRecord>(data['data']); + return deserialize<_i42.PatientConsentRecord>(data['data']); } if (dataClassName == 'PatientDataOverview') { - return deserialize<_i41.PatientDataOverview>(data['data']); + return deserialize<_i43.PatientDataOverview>(data['data']); } if (dataClassName == 'PatientDataSubjectRequestRecord') { - return deserialize<_i42.PatientDataSubjectRequestRecord>(data['data']); + return deserialize<_i44.PatientDataSubjectRequestRecord>(data['data']); } if (dataClassName == 'PatientRiskEvent') { - return deserialize<_i43.PatientRiskEvent>(data['data']); + return deserialize<_i45.PatientRiskEvent>(data['data']); } if (dataClassName == 'RedAlertResult') { - return deserialize<_i44.RedAlertResult>(data['data']); + return deserialize<_i46.RedAlertResult>(data['data']); } if (dataClassName == 'ServiceHealth') { - return deserialize<_i45.ServiceHealth>(data['data']); + return deserialize<_i47.ServiceHealth>(data['data']); } if (dataClassName == 'TermsChangeNotice') { - return deserialize<_i46.TermsChangeNotice>(data['data']); + return deserialize<_i48.TermsChangeNotice>(data['data']); } if (dataClassName == 'TermsVersionEntry') { - return deserialize<_i47.TermsVersionEntry>(data['data']); + return deserialize<_i49.TermsVersionEntry>(data['data']); } if (dataClassName == 'TotpEnrollmentStart') { - return deserialize<_i48.TotpEnrollmentStart>(data['data']); + return deserialize<_i50.TotpEnrollmentStart>(data['data']); } if (dataClassName == 'TriageResult') { - return deserialize<_i49.TriageResult>(data['data']); + return deserialize<_i51.TriageResult>(data['data']); } if (dataClassName == 'UbsContact') { - return deserialize<_i50.UbsContact>(data['data']); + return deserialize<_i52.UbsContact>(data['data']); } if (dataClassName == 'VisitSyncEntry') { - return deserialize<_i51.VisitSyncEntry>(data['data']); + return deserialize<_i53.VisitSyncEntry>(data['data']); } if (dataClassName == 'VisitSyncResult') { - return deserialize<_i52.VisitSyncResult>(data['data']); + return deserialize<_i54.VisitSyncResult>(data['data']); } if (dataClassName == 'AuditChainCheckpoint') { - return deserialize<_i53.AuditChainCheckpoint>(data['data']); + return deserialize<_i55.AuditChainCheckpoint>(data['data']); } if (dataClassName == 'AuditExport') { - return deserialize<_i54.AuditExport>(data['data']); + return deserialize<_i56.AuditExport>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i55.AuditLog>(data['data']); + return deserialize<_i57.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i56.ConsentLog>(data['data']); + return deserialize<_i58.ConsentLog>(data['data']); } if (dataClassName == 'DataSubjectRequest') { - return deserialize<_i57.DataSubjectRequest>(data['data']); + return deserialize<_i59.DataSubjectRequest>(data['data']); + } + if (dataClassName == 'DeviceWipeOrder') { + return deserialize<_i60.DeviceWipeOrder>(data['data']); } if (dataClassName == 'EnrollmentToken') { - return deserialize<_i58.EnrollmentToken>(data['data']); + return deserialize<_i61.EnrollmentToken>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i59.AlertStatus>(data['data']); + return deserialize<_i62.AlertStatus>(data['data']); } if (dataClassName == 'ArrivalMethod') { - return deserialize<_i60.ArrivalMethod>(data['data']); + return deserialize<_i63.ArrivalMethod>(data['data']); } if (dataClassName == 'AuditChainReason') { - return deserialize<_i61.AuditChainReason>(data['data']); + return deserialize<_i64.AuditChainReason>(data['data']); } if (dataClassName == 'AuditIntegrityState') { - return deserialize<_i62.AuditIntegrityState>(data['data']); + return deserialize<_i65.AuditIntegrityState>(data['data']); } if (dataClassName == 'AuditRowSeal') { - return deserialize<_i63.AuditRowSeal>(data['data']); + return deserialize<_i66.AuditRowSeal>(data['data']); } if (dataClassName == 'ConsentPurpose') { - return deserialize<_i64.ConsentPurpose>(data['data']); + return deserialize<_i67.ConsentPurpose>(data['data']); } if (dataClassName == 'DataSubjectRequestStatus') { - return deserialize<_i65.DataSubjectRequestStatus>(data['data']); + return deserialize<_i68.DataSubjectRequestStatus>(data['data']); } if (dataClassName == 'DataSubjectRequestType') { - return deserialize<_i66.DataSubjectRequestType>(data['data']); + return deserialize<_i69.DataSubjectRequestType>(data['data']); + } + if (dataClassName == 'DeviceWipeReason') { + return deserialize<_i70.DeviceWipeReason>(data['data']); + } + if (dataClassName == 'DeviceWipeState') { + return deserialize<_i71.DeviceWipeState>(data['data']); } if (dataClassName == 'EscalationReason') { - return deserialize<_i67.EscalationReason>(data['data']); + return deserialize<_i72.EscalationReason>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i68.RiskLevel>(data['data']); + return deserialize<_i73.RiskLevel>(data['data']); } if (dataClassName == 'ScheduledNoticeStatus') { - return deserialize<_i69.ScheduledNoticeStatus>(data['data']); + return deserialize<_i74.ScheduledNoticeStatus>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i70.SyncStatus>(data['data']); + return deserialize<_i75.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i71.UserRole>(data['data']); + return deserialize<_i76.UserRole>(data['data']); } if (dataClassName == 'VisitAuthorship') { - return deserialize<_i72.VisitAuthorship>(data['data']); + return deserialize<_i77.VisitAuthorship>(data['data']); } if (dataClassName == 'AdminInvalidRequestException') { - return deserialize<_i73.AdminInvalidRequestException>(data['data']); + return deserialize<_i78.AdminInvalidRequestException>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i74.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i79.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i75.AlertPermissionException>(data['data']); + return deserialize<_i80.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i76.AlertValidationException>(data['data']); + return deserialize<_i81.AlertValidationException>(data['data']); } if (dataClassName == 'AuthenticationFailedException') { - return deserialize<_i77.AuthenticationFailedException>(data['data']); + return deserialize<_i82.AuthenticationFailedException>(data['data']); } if (dataClassName == 'DataRightsException') { - return deserialize<_i78.DataRightsException>(data['data']); + return deserialize<_i83.DataRightsException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i79.EndpointDisabledException>(data['data']); + return deserialize<_i84.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i80.EnrollmentException>(data['data']); + return deserialize<_i85.EnrollmentException>(data['data']); } if (dataClassName == 'MfaEnrollmentRequiredException') { - return deserialize<_i81.MfaEnrollmentRequiredException>(data['data']); + return deserialize<_i86.MfaEnrollmentRequiredException>(data['data']); } if (dataClassName == 'MfaRequiredException') { - return deserialize<_i82.MfaRequiredException>(data['data']); + return deserialize<_i87.MfaRequiredException>(data['data']); } if (dataClassName == 'NoticeDeliveryException') { - return deserialize<_i83.NoticeDeliveryException>(data['data']); + return deserialize<_i88.NoticeDeliveryException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i84.OtpRequestException>(data['data']); + return deserialize<_i89.OtpRequestException>(data['data']); } if (dataClassName == 'SessionExpiredException') { - return deserialize<_i85.SessionExpiredException>(data['data']); + return deserialize<_i90.SessionExpiredException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i86.MicroArea>(data['data']); + return deserialize<_i91.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i87.OtpChallenge>(data['data']); + return deserialize<_i92.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i88.Patient>(data['data']); + return deserialize<_i93.Patient>(data['data']); } if (dataClassName == 'PushToken') { - return deserialize<_i89.PushToken>(data['data']); + return deserialize<_i94.PushToken>(data['data']); } if (dataClassName == 'ScheduledNotice') { - return deserialize<_i90.ScheduledNotice>(data['data']); + return deserialize<_i95.ScheduledNotice>(data['data']); } if (dataClassName == 'StaffAccount') { - return deserialize<_i91.StaffAccount>(data['data']); + return deserialize<_i96.StaffAccount>(data['data']); } if (dataClassName == 'TermsVersion') { - return deserialize<_i92.TermsVersion>(data['data']); + return deserialize<_i97.TermsVersion>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i93.TriageAnswer>(data['data']); + return deserialize<_i98.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i94.TriageSession>(data['data']); + return deserialize<_i99.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i95.Ubs>(data['data']); + return deserialize<_i100.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i96.User>(data['data']); + return deserialize<_i101.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i97.UserCredential>(data['data']); + return deserialize<_i102.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i98.Visit>(data['data']); + return deserialize<_i103.Visit>(data['data']); } if (dataClassName.startsWith('serverpod.')) { data['className'] = dataClassName.substring(10); @@ -4392,42 +4579,44 @@ class Protocol extends _i1.SerializationManagerServer { return _i8.AlertIdempotencyKey.t; case _i9.AlertOutboxEntry: return _i9.AlertOutboxEntry.t; - case _i53.AuditChainCheckpoint: - return _i53.AuditChainCheckpoint.t; - case _i54.AuditExport: - return _i54.AuditExport.t; - case _i55.AuditLog: - return _i55.AuditLog.t; - case _i56.ConsentLog: - return _i56.ConsentLog.t; - case _i57.DataSubjectRequest: - return _i57.DataSubjectRequest.t; - case _i58.EnrollmentToken: - return _i58.EnrollmentToken.t; - case _i86.MicroArea: - return _i86.MicroArea.t; - case _i87.OtpChallenge: - return _i87.OtpChallenge.t; - case _i88.Patient: - return _i88.Patient.t; - case _i89.PushToken: - return _i89.PushToken.t; - case _i90.ScheduledNotice: - return _i90.ScheduledNotice.t; - case _i91.StaffAccount: - return _i91.StaffAccount.t; - case _i92.TermsVersion: - return _i92.TermsVersion.t; - case _i94.TriageSession: - return _i94.TriageSession.t; - case _i95.Ubs: - return _i95.Ubs.t; - case _i96.User: - return _i96.User.t; - case _i97.UserCredential: - return _i97.UserCredential.t; - case _i98.Visit: - return _i98.Visit.t; + case _i55.AuditChainCheckpoint: + return _i55.AuditChainCheckpoint.t; + case _i56.AuditExport: + return _i56.AuditExport.t; + case _i57.AuditLog: + return _i57.AuditLog.t; + case _i58.ConsentLog: + return _i58.ConsentLog.t; + case _i59.DataSubjectRequest: + return _i59.DataSubjectRequest.t; + case _i60.DeviceWipeOrder: + return _i60.DeviceWipeOrder.t; + case _i61.EnrollmentToken: + return _i61.EnrollmentToken.t; + case _i91.MicroArea: + return _i91.MicroArea.t; + case _i92.OtpChallenge: + return _i92.OtpChallenge.t; + case _i93.Patient: + return _i93.Patient.t; + case _i94.PushToken: + return _i94.PushToken.t; + case _i95.ScheduledNotice: + return _i95.ScheduledNotice.t; + case _i96.StaffAccount: + return _i96.StaffAccount.t; + case _i97.TermsVersion: + return _i97.TermsVersion.t; + case _i99.TriageSession: + return _i99.TriageSession.t; + case _i100.Ubs: + return _i100.Ubs.t; + case _i101.User: + return _i101.User.t; + case _i102.UserCredential: + return _i102.UserCredential.t; + case _i103.Visit: + return _i103.Visit.t; } return null; } diff --git a/backend/sinalacs_server/lib/src/generated/protocol.yaml b/backend/sinalacs_server/lib/src/generated/protocol.yaml index 1d1ec43..4e1b290 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.yaml +++ b/backend/sinalacs_server/lib/src/generated/protocol.yaml @@ -20,6 +20,8 @@ admin: - resetAcsPassword: - resetAcsMfa: - resetStaffMfa: + - acsDevices: + - orderDeviceWipe: - escalateAlert: - scheduleNotice: - cancelScheduledNotice: diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_device_wipe_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_device_wipe_store.dart new file mode 100644 index 0000000..2f77498 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_device_wipe_store.dart @@ -0,0 +1,164 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/devices/device_wipe_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Pedidos de limpeza remota e aparelhos do ACS sobre Postgres (issue #81). +/// +/// SQL direto com parâmetros nomeados, como `OrmAdminAccountStore`: a listagem +/// junta duas tabelas de token e o pedido. Nada aqui concatena valor no texto +/// da consulta. +class OrmDeviceWipeStore implements DeviceWipeStore { + OrmDeviceWipeStore({required Session Function() session}) : _session = session; + + final Session Function() _session; + + @override + Future> devicesOf(String acsId) async { + if (!_uuidValido(acsId)) return const []; + final rows = await _session().db.unsafeQuery( + 'SELECT d."deviceId", MAX(d."issuedAt") AS "lastSeenAt", ' + 'o."requestedAt", o."deliveredAt", o."confirmedAt" ' + 'FROM (' + 'SELECT "deviceId", "issuedAt" FROM acs_refresh_tokens ' + 'WHERE "userId" = @acs::uuid ' + 'UNION ALL ' + 'SELECT "deviceId", "issuedAt" FROM acs_upload_tokens ' + 'WHERE "userId" = @acs::uuid' + ') d ' + 'LEFT JOIN device_wipe_orders o ON o."deviceId" = d."deviceId" ' + 'GROUP BY d."deviceId", o."requestedAt", o."deliveredAt", o."confirmedAt" ' + 'ORDER BY "lastSeenAt" DESC, d."deviceId"', + parameters: QueryParameters.named({'acs': acsId}), + ); + return [ + for (final r in rows) + () { + final id = r[0]! as String; + final pedido = (r[2] as DateTime?)?.toUtc(); + final entregue = r[3] as DateTime?; + final confirmado = (r[4] as DateTime?)?.toUtc(); + return AdminAcsDevice( + deviceId: id, + label: 'Aparelho ${id.length > 8 ? id.substring(0, 8) : id}', + lastSeenAt: (r[1]! as DateTime).toUtc(), + wipeState: pedido == null + ? null + : confirmado != null + ? DeviceWipeState.confirmed + : entregue != null + ? DeviceWipeState.delivered + : DeviceWipeState.pending, + wipeRequestedAt: pedido, + wipeConfirmedAt: confirmado, + ); + }(), + ]; + } + + @override + Future deviceBelongsTo(String acsId, String deviceId) async { + if (!_uuidValido(acsId)) return false; + final rows = await _session().db.unsafeQuery( + 'SELECT 1 FROM acs_refresh_tokens ' + 'WHERE "userId" = @acs::uuid AND "deviceId" = @d ' + 'UNION ALL ' + 'SELECT 1 FROM acs_upload_tokens ' + 'WHERE "userId" = @acs::uuid AND "deviceId" = @d LIMIT 1', + parameters: QueryParameters.named({'acs': acsId, 'd': deviceId}), + ); + return rows.isNotEmpty; + } + + @override + Future<({DeviceWipeOrder order, bool created})> orderWipe({ + required String deviceId, + required String targetUserId, + required String requestedBy, + required DeviceWipeReason reason, + required DateTime at, + }) { + final session = _session(); + return session.db.transaction((transaction) async { + final inseridas = await session.db.unsafeQuery( + 'INSERT INTO device_wipe_orders ' + '("deviceId", "targetUserId", "requestedBy", reason, "requestedAt") ' + 'VALUES (@d, @alvo::uuid, @autor::uuid, @motivo, @at) ' + 'ON CONFLICT ("deviceId") DO NOTHING RETURNING id', + parameters: QueryParameters.named({ + 'd': deviceId, + 'alvo': targetUserId, + 'autor': requestedBy, + 'motivo': reason.name, + 'at': at.toUtc(), + }), + transaction: transaction, + ); + final created = inseridas.isNotEmpty; + final order = await DeviceWipeOrder.db.findFirstRow( + session, + where: (t) => t.deviceId.equals(deviceId), + transaction: transaction, + ); + if (order == null) { + throw StateError('pedido de limpeza sumiu dentro da transação'); + } + if (created) { + for (final tabela in ['acs_refresh_tokens', 'acs_upload_tokens']) { + await session.db.unsafeExecute( + 'UPDATE $tabela SET "revokedAt" = @at ' + 'WHERE "deviceId" = @d AND "revokedAt" IS NULL', + parameters: QueryParameters.named({'at': at.toUtc(), 'd': deviceId}), + transaction: transaction, + ); + } + } + return (order: order, created: created); + }); + } + + @override + Future orderFor(String deviceId) => + DeviceWipeOrder.db.findFirstRow( + _session(), + where: (t) => t.deviceId.equals(deviceId), + ); + + @override + Future markDelivered(String orderId, DateTime at) async { + if (!_uuidValido(orderId)) return false; + final linhas = await _session().db.unsafeExecute( + 'UPDATE device_wipe_orders SET "deliveredAt" = @at ' + 'WHERE id = @id::uuid AND "deliveredAt" IS NULL', + parameters: QueryParameters.named({'at': at.toUtc(), 'id': orderId}), + ); + return linhas == 1; + } + + @override + Future markConfirmed( + String deviceId, + String orderId, + DateTime at, + ) async { + if (!_uuidValido(orderId)) return false; + final linhas = await _session().db.unsafeExecute( + 'UPDATE device_wipe_orders SET "confirmedAt" = @at ' + 'WHERE id = @id::uuid AND "deviceId" = @d AND "confirmedAt" IS NULL', + parameters: QueryParameters.named({ + 'at': at.toUtc(), + 'id': orderId, + 'd': deviceId, + }), + ); + return linhas == 1; + } + + static bool _uuidValido(String id) { + try { + UuidValue.withFormatValidation(id); + return true; + } on FormatException { + return false; + } + } +} diff --git a/backend/sinalacs_server/lib/src/models/api/admin_acs_device.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_acs_device.spy.yaml new file mode 100644 index 0000000..1fbd76e --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_acs_device.spy.yaml @@ -0,0 +1,13 @@ +### Aparelho de um ACS na listagem do backoffice (#81). O rótulo é derivado do +### `deviceId` (`Aparelho ` + 8 primeiros caracteres): o app não envia modelo +### nem nome do aparelho, e o id completo não precisa chegar à tela. +class: AdminAcsDevice +fields: + deviceId: String + label: String + ### Maior `issuedAt` entre os tokens do aparelho. + lastSeenAt: DateTime + ### `null` = sem pedido de limpeza. + wipeState: DeviceWipeState? + wipeRequestedAt: DateTime? + wipeConfirmedAt: DateTime? diff --git a/backend/sinalacs_server/lib/src/models/api/admin_device_wipe_result.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_device_wipe_result.spy.yaml new file mode 100644 index 0000000..44bca76 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_device_wipe_result.spy.yaml @@ -0,0 +1,7 @@ +### Resposta do pedido de limpeza remota (#81). Repetir o pedido é idempotente: +### `alreadyOrdered` e o `requestedAt` original. +class: AdminDeviceWipeResult +fields: + orderId: String + requestedAt: DateTime + alreadyOrdered: bool diff --git a/backend/sinalacs_server/lib/src/models/device_wipe_order.spy.yaml b/backend/sinalacs_server/lib/src/models/device_wipe_order.spy.yaml new file mode 100644 index 0000000..9819bbb --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/device_wipe_order.spy.yaml @@ -0,0 +1,24 @@ +### Pedido de limpeza remota do aparelho de um ACS (#81). Um por aparelho +### (índice único em `deviceId`): pedir de novo devolve o pedido original. +### +### O pedido não carrega dado pessoal: só ids, o motivo (conjunto fechado) e os +### carimbos. `deliveredAt` marca a primeira vez que o aparelho foi mandado +### limpar; `confirmedAt`, a confirmação do próprio aparelho de que limpou. +class: DeviceWipeOrder +table: device_wipe_orders +fields: + id: UuidValue?, defaultPersist=random + ### Instalação alvo (`acs_refresh_tokens.deviceId`). + deviceId: String + ### O ACS dono do aparelho no momento do pedido. + targetUserId: UuidValue, relation(parent=users) + ### Quem pediu (coordenador ou administrador). + requestedBy: UuidValue, relation(parent=users) + reason: DeviceWipeReason + requestedAt: DateTime + deliveredAt: DateTime? + confirmedAt: DateTime? +indexes: + device_wipe_orders_device_key: + fields: deviceId + unique: true diff --git a/backend/sinalacs_server/lib/src/models/enums/device_wipe_reason.spy.yaml b/backend/sinalacs_server/lib/src/models/enums/device_wipe_reason.spy.yaml new file mode 100644 index 0000000..8abeb89 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/enums/device_wipe_reason.spy.yaml @@ -0,0 +1,9 @@ +### Motivo da limpeza remota de um aparelho (#81). Conjunto fechado, sem texto +### livre: o motivo vai para a trilha de auditoria e texto livre ali seria um +### canal para dado pessoal. +enum: DeviceWipeReason +serialized: byName +values: + - lost + - stolen + - replaced diff --git a/backend/sinalacs_server/lib/src/models/enums/device_wipe_state.spy.yaml b/backend/sinalacs_server/lib/src/models/enums/device_wipe_state.spy.yaml new file mode 100644 index 0000000..25ce6fd --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/enums/device_wipe_state.spy.yaml @@ -0,0 +1,9 @@ +### Estado do pedido de limpeza de um aparelho (#81), derivado dos carimbos do +### pedido: `pending` (pedido, aparelho ainda não mandado limpar), `delivered` +### (ordem entregue ao aparelho) e `confirmed` (aparelho confirmou a limpeza). +enum: DeviceWipeState +serialized: byName +values: + - pending + - delivered + - confirmed diff --git a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart index 20c84e2..e5ee146 100644 --- a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart +++ b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart @@ -1,5 +1,6 @@ import 'package:sinalacs_server/src/application/audit/audit_chain.dart'; import 'package:sinalacs_server/src/application/admin/admin_account_service.dart'; +import 'package:sinalacs_server/src/application/devices/device_wipe_service.dart'; import 'package:sinalacs_server/src/application/admin/admin_monitoring_service.dart'; import 'package:sinalacs_server/src/application/admin/admin_read_service.dart'; import 'package:sinalacs_server/src/application/admin/alert_escalation_service.dart'; @@ -8,6 +9,7 @@ import 'package:sinalacs_server/src/application/admin/audit_export_service.dart' import 'package:sinalacs_server/src/application/admin/data_subject_case_service.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_data_subject_case_store.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_admin_account_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_device_wipe_store.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_admin_read_store.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_audit_checkpoint_store.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_audit_export_store.dart'; @@ -373,6 +375,15 @@ class AlertRuntime { ); } + /// Limpeza remota e aparelhos do ACS pelo backoffice (#81), para uma + /// requisição. O escopo e a existência do ACS vêm do mesmo store de contas da + /// #43; a trilha é a da requisição. + DeviceWipeService deviceWipeServiceFor(Session session) => DeviceWipeService( + store: OrmDeviceWipeStore(session: () => session), + accounts: OrmAdminAccountStore(session: () => session), + audit: auditTrailFor(session), + ); + /// Escalonamento de alerta vermelho pelo backoffice (#44), para uma /// requisição. O escalonamento gravado é auditado DENTRO da transação do /// store (`recordInTransaction`), junto do `UPDATE` e do reforço no outbox; diff --git a/backend/sinalacs_server/migrations/20261017171640323/definition.json b/backend/sinalacs_server/migrations/20261017171640323/definition.json new file mode 100644 index 0000000..49e0ca1 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261017171640323/definition.json @@ -0,0 +1,4656 @@ +{ + "__className__": "serverpod.DatabaseDefinition", + "moduleName": "sinalacs", + "tables": [ + { + "__className__": "serverpod.TableDefinition", + "name": "acs", + "dartName": "Acs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "enrollmentId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSyncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_deliveries", + "dartName": "AlertDeliveryRecord", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_alert_acs_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "alertId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "acsId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_idempotency_keys", + "dartName": "AlertIdempotencyKey", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "key", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_idempotency_keys_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_key_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "key" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_outbox", + "dartName": "AlertOutboxEntry", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "topic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "payload", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "nextAttemptAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastError", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_outbox_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pending_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "publishedAt" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "nextAttemptAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alerts", + "dartName": "Alert", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "triggeredAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "receivedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "respondedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolvedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevel", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationCell", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:AlertStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "mqttTopic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "retryCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalationReason", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:EscalationReason?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_micro_area_status_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "triggeredAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_chain_checkpoints", + "dartName": "AuditChainCheckpoint", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_exports", + "dartName": "AuditExport", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "filtersJson", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "format", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rowCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "firstSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contentSha256", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chainState", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_exports_fk_0", + "columns": [ + "requestedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_requested_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestedAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_logs", + "dartName": "AuditLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "actionType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "result", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "previousHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_user_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "consent_logs", + "dartName": "ConsentLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "purpose", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "action", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userAgent", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "consent_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "consent_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "data_subject_requests", + "dartName": "DataSubjectRequest", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "device_wipe_orders", + "dartName": "DeviceWipeOrder", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "targetUserId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "reason", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DeviceWipeReason" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deliveredAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "confirmedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "device_wipe_orders_fk_0", + "columns": [ + "targetUserId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "device_wipe_orders_fk_1", + "columns": [ + "requestedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "device_wipe_orders_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "device_wipe_orders_device_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "micro_areas", + "dartName": "MicroArea", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "geoJsonBoundary", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "otp_challenges", + "dartName": "OtpChallenge", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "patients", + "dartName": "Patient", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastLocationHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastTriageAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "patients_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "push_tokens", + "dartName": "PushToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "scheduled_notices", + "dartName": "ScheduledNotice", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "audience", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "title", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "message", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledFor", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:ScheduledNoticeStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "recipients", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "accepted", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failureReason", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sentAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "scheduled_notices_fk_0", + "columns": [ + "createdBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "scheduled_notices_fk_1", + "columns": [ + "microAreaId" + ], + "referenceTable": "micro_areas", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_due_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "scheduledFor" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "enrollmentId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "terms_versions", + "dartName": "TermsVersion", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "summary", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "effectiveFrom", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "legallyReviewedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "legallyReviewedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_0", + "columns": [ + "createdBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_1", + "columns": [ + "legallyReviewedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_2", + "columns": [ + "publishedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "terms_versions_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "terms_versions_version_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "version" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "triage_sessions", + "dartName": "TriageSession", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultRiskEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultRiskKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "triage_sessions_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "triage_sessions_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "ubs", + "dartName": "Ubs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "address", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "city", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "state", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "ubs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "user_credentials", + "dartName": "UserCredential", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "users", + "dartName": "User", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "cpfHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "birthDate", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "role", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:UserRole" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_cpf_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_micro_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "visits", + "dartName": "Visit", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "startedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "completedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelBeforeEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelBeforeKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelAfterEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelAfterKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "localId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_local_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "localId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_cloud_storage", + "dartName": "CloudStorageEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_cloud_storage_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "storageId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "path", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "addedTime", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiration", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "byteData", + "columnType": 5, + "isNullable": false, + "dartType": "dart:typed_data:ByteData" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "verified", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_path_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "storageId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "path" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_expiration", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "expiration" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_cloud_storage_direct_upload", + "dartName": "CloudStorageDirectUploadEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_cloud_storage_direct_upload_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "storageId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "path", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiration", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authKey", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_direct_upload_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_direct_upload_storage_path", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "storageId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "path" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_future_call", + "dartName": "FutureCallEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_future_call_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "time", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serializedObject", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "identifier", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_time_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "time" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_serverId_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_identifier_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "identifier" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_health_connection_info", + "dartName": "ServerHealthConnectionInfo", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_health_connection_info_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "active", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "closing", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idle", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "granularity", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_connection_info_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_connection_info_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "granularity" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_health_metric", + "dartName": "ServerHealthMetric", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_health_metric_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isHealthy", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "value", + "columnType": 3, + "isNullable": false, + "dartType": "double" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "granularity", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_metric_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_metric_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "name" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "granularity" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_log", + "dartName": "LogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sessionLogId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageId", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "reference", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "time", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logLevel", + "columnType": 6, + "isNullable": false, + "dartType": "protocol:LogLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "message", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "order", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "serverpod_log_fk_0", + "columns": [ + "sessionLogId" + ], + "referenceTable": "serverpod_session_log", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 4 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_log_sessionLogId_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sessionLogId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_message_log", + "dartName": "MessageLogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_message_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sessionLogId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "endpoint", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageName", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "duration", + "columnType": 3, + "isNullable": false, + "dartType": "double" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "slow", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "order", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "serverpod_message_log_fk_0", + "columns": [ + "sessionLogId" + ], + "referenceTable": "serverpod_session_log", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 4 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_message_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_method", + "dartName": "MethodInfo", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_method_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "endpoint", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "method", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_method_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_method_endpoint_method_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "endpoint" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "method" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_migrations", + "dartName": "DatabaseMigrationVersion", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_migrations_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "module", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_migrations_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_migrations_ids", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "module" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_query_log", + "dartName": "QueryLogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_query_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sessionLogId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageId", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "query", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "duration", + "columnType": 3, + "isNullable": false, + "dartType": "double" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "numRows", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "slow", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "order", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "serverpod_query_log_fk_0", + "columns": [ + "sessionLogId" + ], + "referenceTable": "serverpod_session_log", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 4 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_query_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_query_log_sessionLogId_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sessionLogId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_readwrite_test", + "dartName": "ReadWriteTestEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_readwrite_test_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "number", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_readwrite_test_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_runtime_settings", + "dartName": "RuntimeSettings", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_runtime_settings_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logSettings", + "columnType": 8, + "isNullable": false, + "dartType": "protocol:LogSettings" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logSettingsOverrides", + "columnType": 8, + "isNullable": false, + "dartType": "List" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logServiceCalls", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logMalformedCalls", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_runtime_settings_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_session_log", + "dartName": "SessionLogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_session_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "time", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "module", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "endpoint", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "method", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "duration", + "columnType": 3, + "isNullable": true, + "dartType": "double?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "numQueries", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "slow", + "columnType": 1, + "isNullable": true, + "dartType": "bool?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authenticatedUserId", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isOpen", + "columnType": 1, + "isNullable": true, + "dartType": "bool?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "touched", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_serverid_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_time_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "time" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_touched_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "touched" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_isopen_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "isOpen" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + } + ], + "installedModules": [ + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "sinalacs", + "version": "20261017171640323" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261017171640323/definition.sql b/backend/sinalacs_server/migrations/20261017171640323/definition.sql new file mode 100644 index 0000000..2f8b2fc --- /dev/null +++ b/backend/sinalacs_server/migrations/20261017171640323/definition.sql @@ -0,0 +1,972 @@ +BEGIN; + +-- +-- Class Acs as table acs +-- +CREATE TABLE "acs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "ubsId" uuid NOT NULL, + "active" boolean NOT NULL, + "lastSyncAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "acs_ubs_idx" ON "acs" USING btree ("ubsId"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AcsRefreshToken as table acs_refresh_tokens +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- Class AcsUploadToken as table acs_upload_tokens +-- +CREATE TABLE "acs_upload_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_upload_tokens_hash_key" ON "acs_upload_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_upload_tokens_user_device_idx" ON "acs_upload_tokens" USING btree ("userId", "deviceId"); + +-- +-- Class AlertDeliveryRecord as table alert_deliveries +-- +CREATE TABLE "alert_deliveries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "alertId" uuid NOT NULL, + "acsId" uuid NOT NULL, + "acknowledgedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "alert_deliveries_alert_acs_key" ON "alert_deliveries" USING btree ("alertId", "acsId"); + +-- +-- Class AlertIdempotencyKey as table alert_idempotency_keys +-- +CREATE TABLE "alert_idempotency_keys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "key" text NOT NULL, + "alertId" uuid NOT NULL, + "locationHash" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "alert_idempotency_keys_key_key" ON "alert_idempotency_keys" USING btree ("key"); + +-- +-- Class AlertOutboxEntry as table alert_outbox +-- +CREATE TABLE "alert_outbox" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "alertId" uuid NOT NULL, + "topic" text NOT NULL, + "payload" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "publishedAt" timestamp without time zone, + "attempts" bigint NOT NULL, + "nextAttemptAt" timestamp without time zone NOT NULL, + "lastError" text +); + +-- Indexes +CREATE INDEX "alert_outbox_pending_idx" ON "alert_outbox" USING btree ("publishedAt", "nextAttemptAt"); + +-- +-- Class Alert as table alerts +-- +CREATE TABLE "alerts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "acsId" uuid, + "microAreaId" uuid, + "triggeredAt" timestamp without time zone NOT NULL, + "receivedAt" timestamp without time zone, + "respondedAt" timestamp without time zone, + "acknowledgedAt" timestamp without time zone, + "resolvedAt" timestamp without time zone, + "riskLevel" text NOT NULL, + "locationHash" text NOT NULL, + "locationCell" text, + "status" text NOT NULL, + "mqttTopic" text NOT NULL, + "deviceId" text NOT NULL, + "retryCount" bigint NOT NULL, + "version" bigint NOT NULL, + "escalatedAt" timestamp without time zone, + "escalatedBy" uuid, + "escalationReason" text +); + +-- Indexes +CREATE INDEX "alerts_micro_area_status_idx" ON "alerts" USING btree ("microAreaId", "status", "triggeredAt"); + +-- +-- Class AuditChainCheckpoint as table audit_chain_checkpoints +-- +CREATE TABLE "audit_chain_checkpoints" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "sequence" bigint NOT NULL, + "entryHash" text NOT NULL, + "signature" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "audit_chain_checkpoints_sequence_idx" ON "audit_chain_checkpoints" USING btree ("sequence"); + +-- +-- Class AuditExport as table audit_exports +-- +CREATE TABLE "audit_exports" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "requestedBy" uuid NOT NULL, + "requestedAt" timestamp without time zone NOT NULL, + "filtersJson" text NOT NULL, + "format" text NOT NULL, + "rowCount" bigint NOT NULL, + "firstSequence" bigint, + "lastSequence" bigint, + "contentSha256" text NOT NULL, + "chainState" text NOT NULL +); + +-- Indexes +CREATE INDEX "audit_exports_requested_at_idx" ON "audit_exports" USING btree ("requestedAt"); + +-- +-- Class AuditLog as table audit_logs +-- +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "actionType" text NOT NULL, + "resourceType" text NOT NULL, + "resourceId" uuid, + "timestamp" timestamp without time zone NOT NULL, + "ipHash" text NOT NULL, + "result" text NOT NULL, + "sequence" bigint NOT NULL, + "previousHash" text NOT NULL, + "entryHash" text NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "audit_logs_sequence_idx" ON "audit_logs" USING btree ("sequence"); +CREATE INDEX "audit_logs_timestamp_idx" ON "audit_logs" USING btree ("timestamp"); +CREATE INDEX "audit_logs_user_sequence_idx" ON "audit_logs" USING btree ("userId", "sequence"); + +-- +-- Class ConsentLog as table consent_logs +-- +CREATE TABLE "consent_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "purpose" text NOT NULL, + "action" text NOT NULL, + "version" text NOT NULL, + "timestamp" timestamp without time zone NOT NULL, + "ipHash" text NOT NULL, + "userAgent" text NOT NULL, + "signature" text NOT NULL +); + +-- +-- Class DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL, + "decidedAt" timestamp without time zone, + "decidedBy" uuid, + "resolutionEncrypted" text, + "resolutionKeyVersion" bigint +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class DeviceWipeOrder as table device_wipe_orders +-- +CREATE TABLE "device_wipe_orders" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "deviceId" text NOT NULL, + "targetUserId" uuid NOT NULL, + "requestedBy" uuid NOT NULL, + "reason" text NOT NULL, + "requestedAt" timestamp without time zone NOT NULL, + "deliveredAt" timestamp without time zone, + "confirmedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "device_wipe_orders_device_key" ON "device_wipe_orders" USING btree ("deviceId"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- Class MicroArea as table micro_areas +-- +CREATE TABLE "micro_areas" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "name" text NOT NULL, + "ubsId" uuid NOT NULL, + "geoJsonBoundary" text NOT NULL +); + +-- Indexes +CREATE INDEX "micro_areas_ubs_idx" ON "micro_areas" USING btree ("ubsId"); + +-- +-- Class OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContactEncrypted" text NOT NULL DEFAULT ''::text, + "emergencyContactKeyVersion" bigint NOT NULL DEFAULT 1, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class ScheduledNotice as table scheduled_notices +-- +CREATE TABLE "scheduled_notices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "createdBy" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "audience" text NOT NULL, + "title" text NOT NULL, + "message" text NOT NULL, + "scheduledFor" timestamp without time zone NOT NULL, + "status" text NOT NULL, + "recipients" bigint, + "accepted" bigint, + "failureReason" text, + "createdAt" timestamp without time zone NOT NULL, + "sentAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "scheduled_notices_due_idx" ON "scheduled_notices" USING btree ("status", "scheduledFor"); +CREATE INDEX "scheduled_notices_area_idx" ON "scheduled_notices" USING btree ("microAreaId", "createdAt"); + +-- +-- Class StaffAccount as table staff_accounts +-- +CREATE TABLE "staff_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "active" boolean NOT NULL, + "ubsId" uuid, + "activationCodeHash" text, + "activationCodeExpiresAt" timestamp without time zone, + "activationCodeIssuedBy" text, + "activationCodeIssuedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "staff_accounts_enrollment_id_key" ON "staff_accounts" USING btree ("enrollmentId"); + +-- +-- Class TermsVersion as table terms_versions +-- +CREATE TABLE "terms_versions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "version" text NOT NULL, + "summary" text NOT NULL, + "effectiveFrom" timestamp without time zone NOT NULL, + "createdBy" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "legallyReviewedAt" timestamp without time zone, + "legallyReviewedBy" uuid, + "publishedAt" timestamp without time zone, + "publishedBy" uuid +); + +-- Indexes +CREATE UNIQUE INDEX "terms_versions_version_key" ON "terms_versions" USING btree ("version"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "resultRiskEncrypted" text NOT NULL DEFAULT ''::text, + "resultRiskKeyVersion" bigint NOT NULL DEFAULT 1, + "createdAt" timestamp without time zone NOT NULL, + "deviceId" text NOT NULL +); + +-- +-- Class Ubs as table ubs +-- +CREATE TABLE "ubs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "name" text NOT NULL, + "address" text NOT NULL, + "city" text NOT NULL, + "state" text NOT NULL, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "lockStreak" bigint NOT NULL DEFAULT 0, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- Class User as table users +-- +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "cpfHash" text NOT NULL, + "name" text NOT NULL, + "birthDate" timestamp without time zone NOT NULL, + "role" text NOT NULL, + "microAreaId" uuid, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "users_cpf_hash_key" ON "users" USING btree ("cpfHash"); +CREATE INDEX "users_micro_area_idx" ON "users" USING btree ("microAreaId"); + +-- +-- Class Visit as table visits +-- +CREATE TABLE "visits" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "acsId" uuid, + "authorship" text NOT NULL DEFAULT 'acs'::text, + "originDeviceId" text, + "scheduledAt" timestamp without time zone NOT NULL, + "startedAt" timestamp without time zone, + "completedAt" timestamp without time zone, + "status" text NOT NULL, + "riskLevelBeforeEncrypted" text NOT NULL DEFAULT ''::text, + "riskLevelBeforeKeyVersion" bigint NOT NULL DEFAULT 1, + "riskLevelAfterEncrypted" text NOT NULL DEFAULT ''::text, + "riskLevelAfterKeyVersion" bigint NOT NULL DEFAULT 1, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "localId" uuid NOT NULL, + "syncAt" timestamp without time zone, + "version" bigint NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "visits_local_id_key" ON "visits" USING btree ("localId"); + +-- +-- Class CloudStorageEntry as table serverpod_cloud_storage +-- +CREATE TABLE "serverpod_cloud_storage" ( + "id" bigserial PRIMARY KEY, + "storageId" text NOT NULL, + "path" text NOT NULL, + "addedTime" timestamp without time zone NOT NULL, + "expiration" timestamp without time zone, + "byteData" bytea NOT NULL, + "verified" boolean NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_cloud_storage_path_idx" ON "serverpod_cloud_storage" USING btree ("storageId", "path"); +CREATE INDEX "serverpod_cloud_storage_expiration" ON "serverpod_cloud_storage" USING btree ("expiration"); + +-- +-- Class CloudStorageDirectUploadEntry as table serverpod_cloud_storage_direct_upload +-- +CREATE TABLE "serverpod_cloud_storage_direct_upload" ( + "id" bigserial PRIMARY KEY, + "storageId" text NOT NULL, + "path" text NOT NULL, + "expiration" timestamp without time zone NOT NULL, + "authKey" text NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_cloud_storage_direct_upload_storage_path" ON "serverpod_cloud_storage_direct_upload" USING btree ("storageId", "path"); + +-- +-- Class FutureCallEntry as table serverpod_future_call +-- +CREATE TABLE "serverpod_future_call" ( + "id" bigserial PRIMARY KEY, + "name" text NOT NULL, + "time" timestamp without time zone NOT NULL, + "serializedObject" text, + "serverId" text NOT NULL, + "identifier" text +); + +-- Indexes +CREATE INDEX "serverpod_future_call_time_idx" ON "serverpod_future_call" USING btree ("time"); +CREATE INDEX "serverpod_future_call_serverId_idx" ON "serverpod_future_call" USING btree ("serverId"); +CREATE INDEX "serverpod_future_call_identifier_idx" ON "serverpod_future_call" USING btree ("identifier"); + +-- +-- Class ServerHealthConnectionInfo as table serverpod_health_connection_info +-- +CREATE TABLE "serverpod_health_connection_info" ( + "id" bigserial PRIMARY KEY, + "serverId" text NOT NULL, + "timestamp" timestamp without time zone NOT NULL, + "active" bigint NOT NULL, + "closing" bigint NOT NULL, + "idle" bigint NOT NULL, + "granularity" bigint NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_health_connection_info_timestamp_idx" ON "serverpod_health_connection_info" USING btree ("timestamp", "serverId", "granularity"); + +-- +-- Class ServerHealthMetric as table serverpod_health_metric +-- +CREATE TABLE "serverpod_health_metric" ( + "id" bigserial PRIMARY KEY, + "name" text NOT NULL, + "serverId" text NOT NULL, + "timestamp" timestamp without time zone NOT NULL, + "isHealthy" boolean NOT NULL, + "value" double precision NOT NULL, + "granularity" bigint NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_health_metric_timestamp_idx" ON "serverpod_health_metric" USING btree ("timestamp", "serverId", "name", "granularity"); + +-- +-- Class LogEntry as table serverpod_log +-- +CREATE TABLE "serverpod_log" ( + "id" bigserial PRIMARY KEY, + "sessionLogId" bigint NOT NULL, + "messageId" bigint, + "reference" text, + "serverId" text NOT NULL, + "time" timestamp without time zone NOT NULL, + "logLevel" bigint NOT NULL, + "message" text NOT NULL, + "error" text, + "stackTrace" text, + "order" bigint NOT NULL +); + +-- Indexes +CREATE INDEX "serverpod_log_sessionLogId_idx" ON "serverpod_log" USING btree ("sessionLogId"); + +-- +-- Class MessageLogEntry as table serverpod_message_log +-- +CREATE TABLE "serverpod_message_log" ( + "id" bigserial PRIMARY KEY, + "sessionLogId" bigint NOT NULL, + "serverId" text NOT NULL, + "messageId" bigint NOT NULL, + "endpoint" text NOT NULL, + "messageName" text NOT NULL, + "duration" double precision NOT NULL, + "error" text, + "stackTrace" text, + "slow" boolean NOT NULL, + "order" bigint NOT NULL +); + +-- +-- Class MethodInfo as table serverpod_method +-- +CREATE TABLE "serverpod_method" ( + "id" bigserial PRIMARY KEY, + "endpoint" text NOT NULL, + "method" text NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_method_endpoint_method_idx" ON "serverpod_method" USING btree ("endpoint", "method"); + +-- +-- Class DatabaseMigrationVersion as table serverpod_migrations +-- +CREATE TABLE "serverpod_migrations" ( + "id" bigserial PRIMARY KEY, + "module" text NOT NULL, + "version" text NOT NULL, + "timestamp" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_migrations_ids" ON "serverpod_migrations" USING btree ("module"); + +-- +-- Class QueryLogEntry as table serverpod_query_log +-- +CREATE TABLE "serverpod_query_log" ( + "id" bigserial PRIMARY KEY, + "serverId" text NOT NULL, + "sessionLogId" bigint NOT NULL, + "messageId" bigint, + "query" text NOT NULL, + "duration" double precision NOT NULL, + "numRows" bigint, + "error" text, + "stackTrace" text, + "slow" boolean NOT NULL, + "order" bigint NOT NULL +); + +-- Indexes +CREATE INDEX "serverpod_query_log_sessionLogId_idx" ON "serverpod_query_log" USING btree ("sessionLogId"); + +-- +-- Class ReadWriteTestEntry as table serverpod_readwrite_test +-- +CREATE TABLE "serverpod_readwrite_test" ( + "id" bigserial PRIMARY KEY, + "number" bigint NOT NULL +); + +-- +-- Class RuntimeSettings as table serverpod_runtime_settings +-- +CREATE TABLE "serverpod_runtime_settings" ( + "id" bigserial PRIMARY KEY, + "logSettings" json NOT NULL, + "logSettingsOverrides" json NOT NULL, + "logServiceCalls" boolean NOT NULL, + "logMalformedCalls" boolean NOT NULL +); + +-- +-- Class SessionLogEntry as table serverpod_session_log +-- +CREATE TABLE "serverpod_session_log" ( + "id" bigserial PRIMARY KEY, + "serverId" text NOT NULL, + "time" timestamp without time zone NOT NULL, + "module" text, + "endpoint" text, + "method" text, + "duration" double precision, + "numQueries" bigint, + "slow" boolean, + "error" text, + "stackTrace" text, + "authenticatedUserId" bigint, + "userId" text, + "isOpen" boolean, + "touched" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "serverpod_session_log_serverid_idx" ON "serverpod_session_log" USING btree ("serverId"); +CREATE INDEX "serverpod_session_log_time_idx" ON "serverpod_session_log" USING btree ("time"); +CREATE INDEX "serverpod_session_log_touched_idx" ON "serverpod_session_log" USING btree ("touched"); +CREATE INDEX "serverpod_session_log_isopen_idx" ON "serverpod_session_log" USING btree ("isOpen"); + +-- +-- Foreign relations for "acs_refresh_tokens" table +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "acs_upload_tokens" table +-- +ALTER TABLE ONLY "acs_upload_tokens" + ADD CONSTRAINT "acs_upload_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "alert_deliveries" table +-- +ALTER TABLE ONLY "alert_deliveries" + ADD CONSTRAINT "alert_deliveries_fk_0" + FOREIGN KEY("alertId") + REFERENCES "alerts"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "alert_deliveries" + ADD CONSTRAINT "alert_deliveries_fk_1" + FOREIGN KEY("acsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "alert_idempotency_keys" table +-- +ALTER TABLE ONLY "alert_idempotency_keys" + ADD CONSTRAINT "alert_idempotency_keys_fk_0" + FOREIGN KEY("alertId") + REFERENCES "alerts"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "alert_outbox" table +-- +ALTER TABLE ONLY "alert_outbox" + ADD CONSTRAINT "alert_outbox_fk_0" + FOREIGN KEY("alertId") + REFERENCES "alerts"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "alerts" table +-- +ALTER TABLE ONLY "alerts" + ADD CONSTRAINT "alerts_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "alerts" + ADD CONSTRAINT "alerts_fk_1" + FOREIGN KEY("acsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "audit_exports" table +-- +ALTER TABLE ONLY "audit_exports" + ADD CONSTRAINT "audit_exports_fk_0" + FOREIGN KEY("requestedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "audit_logs" table +-- +ALTER TABLE ONLY "audit_logs" + ADD CONSTRAINT "audit_logs_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "consent_logs" table +-- +ALTER TABLE ONLY "consent_logs" + ADD CONSTRAINT "consent_logs_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_1" + FOREIGN KEY("decidedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "device_wipe_orders" table +-- +ALTER TABLE ONLY "device_wipe_orders" + ADD CONSTRAINT "device_wipe_orders_fk_0" + FOREIGN KEY("targetUserId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "device_wipe_orders" + ADD CONSTRAINT "device_wipe_orders_fk_1" + FOREIGN KEY("requestedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "scheduled_notices" table +-- +ALTER TABLE ONLY "scheduled_notices" + ADD CONSTRAINT "scheduled_notices_fk_0" + FOREIGN KEY("createdBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "scheduled_notices" + ADD CONSTRAINT "scheduled_notices_fk_1" + FOREIGN KEY("microAreaId") + REFERENCES "micro_areas"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "terms_versions" table +-- +ALTER TABLE ONLY "terms_versions" + ADD CONSTRAINT "terms_versions_fk_0" + FOREIGN KEY("createdBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "terms_versions" + ADD CONSTRAINT "terms_versions_fk_1" + FOREIGN KEY("legallyReviewedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "terms_versions" + ADD CONSTRAINT "terms_versions_fk_2" + FOREIGN KEY("publishedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "triage_sessions" table +-- +ALTER TABLE ONLY "triage_sessions" + ADD CONSTRAINT "triage_sessions_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "visits" table +-- +ALTER TABLE ONLY "visits" + ADD CONSTRAINT "visits_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "visits" + ADD CONSTRAINT "visits_fk_1" + FOREIGN KEY("acsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "serverpod_log" table +-- +ALTER TABLE ONLY "serverpod_log" + ADD CONSTRAINT "serverpod_log_fk_0" + FOREIGN KEY("sessionLogId") + REFERENCES "serverpod_session_log"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "serverpod_message_log" table +-- +ALTER TABLE ONLY "serverpod_message_log" + ADD CONSTRAINT "serverpod_message_log_fk_0" + FOREIGN KEY("sessionLogId") + REFERENCES "serverpod_session_log"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "serverpod_query_log" table +-- +ALTER TABLE ONLY "serverpod_query_log" + ADD CONSTRAINT "serverpod_query_log_fk_0" + FOREIGN KEY("sessionLogId") + REFERENCES "serverpod_session_log"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION; + + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261017171640323', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261017171640323', "timestamp" = now(); + +-- +-- MIGRATION VERSION FOR serverpod +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('serverpod', '20260129180959368', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20260129180959368', "timestamp" = now(); + + +COMMIT; diff --git a/backend/sinalacs_server/migrations/20261017171640323/definition_project.json b/backend/sinalacs_server/migrations/20261017171640323/definition_project.json new file mode 100644 index 0000000..5abd4d0 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261017171640323/definition_project.json @@ -0,0 +1,3320 @@ +{ + "__className__": "serverpod.DatabaseDefinition", + "moduleName": "sinalacs", + "tables": [ + { + "__className__": "serverpod.TableDefinition", + "name": "acs", + "dartName": "Acs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "enrollmentId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSyncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_deliveries", + "dartName": "AlertDeliveryRecord", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_alert_acs_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "alertId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "acsId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_idempotency_keys", + "dartName": "AlertIdempotencyKey", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "key", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_idempotency_keys_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_key_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "key" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_outbox", + "dartName": "AlertOutboxEntry", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "topic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "payload", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "nextAttemptAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastError", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_outbox_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pending_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "publishedAt" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "nextAttemptAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alerts", + "dartName": "Alert", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "triggeredAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "receivedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "respondedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolvedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevel", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationCell", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:AlertStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "mqttTopic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "retryCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalationReason", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:EscalationReason?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_micro_area_status_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "triggeredAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_chain_checkpoints", + "dartName": "AuditChainCheckpoint", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_exports", + "dartName": "AuditExport", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "filtersJson", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "format", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rowCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "firstSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contentSha256", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chainState", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_exports_fk_0", + "columns": [ + "requestedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_requested_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestedAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_logs", + "dartName": "AuditLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "actionType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "result", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "previousHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_user_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "consent_logs", + "dartName": "ConsentLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "purpose", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "action", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userAgent", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "consent_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "consent_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "data_subject_requests", + "dartName": "DataSubjectRequest", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "device_wipe_orders", + "dartName": "DeviceWipeOrder", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "targetUserId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "reason", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DeviceWipeReason" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deliveredAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "confirmedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "device_wipe_orders_fk_0", + "columns": [ + "targetUserId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "device_wipe_orders_fk_1", + "columns": [ + "requestedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "device_wipe_orders_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "device_wipe_orders_device_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "micro_areas", + "dartName": "MicroArea", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "geoJsonBoundary", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "otp_challenges", + "dartName": "OtpChallenge", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "patients", + "dartName": "Patient", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastLocationHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastTriageAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "patients_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "push_tokens", + "dartName": "PushToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "scheduled_notices", + "dartName": "ScheduledNotice", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "audience", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "title", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "message", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledFor", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:ScheduledNoticeStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "recipients", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "accepted", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failureReason", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sentAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "scheduled_notices_fk_0", + "columns": [ + "createdBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "scheduled_notices_fk_1", + "columns": [ + "microAreaId" + ], + "referenceTable": "micro_areas", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_due_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "scheduledFor" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "enrollmentId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "terms_versions", + "dartName": "TermsVersion", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "summary", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "effectiveFrom", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "legallyReviewedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "legallyReviewedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_0", + "columns": [ + "createdBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_1", + "columns": [ + "legallyReviewedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_2", + "columns": [ + "publishedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "terms_versions_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "terms_versions_version_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "version" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "triage_sessions", + "dartName": "TriageSession", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultRiskEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultRiskKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "triage_sessions_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "triage_sessions_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "ubs", + "dartName": "Ubs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "address", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "city", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "state", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "ubs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "user_credentials", + "dartName": "UserCredential", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "users", + "dartName": "User", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "cpfHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "birthDate", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "role", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:UserRole" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_cpf_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_micro_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "visits", + "dartName": "Visit", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "startedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "completedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelBeforeEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelBeforeKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelAfterEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelAfterKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "localId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_local_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "localId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + } + ], + "installedModules": [ + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261017171640323/migration.json b/backend/sinalacs_server/migrations/20261017171640323/migration.json new file mode 100644 index 0000000..8d9adb3 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261017171640323/migration.json @@ -0,0 +1,138 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "createTable", + "createTable": { + "__className__": "serverpod.TableDefinition", + "name": "device_wipe_orders", + "dartName": "DeviceWipeOrder", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "targetUserId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "reason", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DeviceWipeReason" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deliveredAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "confirmedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "device_wipe_orders_fk_0", + "columns": [ + "targetUserId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "device_wipe_orders_fk_1", + "columns": [ + "requestedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "device_wipe_orders_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "device_wipe_orders_device_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261017171640323/migration.sql b/backend/sinalacs_server/migrations/20261017171640323/migration.sql new file mode 100644 index 0000000..f40aec3 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261017171640323/migration.sql @@ -0,0 +1,54 @@ +BEGIN; + +-- +-- ACTION CREATE TABLE +-- +CREATE TABLE "device_wipe_orders" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "deviceId" text NOT NULL, + "targetUserId" uuid NOT NULL, + "requestedBy" uuid NOT NULL, + "reason" text NOT NULL, + "requestedAt" timestamp without time zone NOT NULL, + "deliveredAt" timestamp without time zone, + "confirmedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "device_wipe_orders_device_key" ON "device_wipe_orders" USING btree ("deviceId"); + +-- +-- ACTION CREATE FOREIGN KEY +-- +ALTER TABLE ONLY "device_wipe_orders" + ADD CONSTRAINT "device_wipe_orders_fk_0" + FOREIGN KEY("targetUserId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "device_wipe_orders" + ADD CONSTRAINT "device_wipe_orders_fk_1" + FOREIGN KEY("requestedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261017171640323', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261017171640323', "timestamp" = now(); + +-- +-- MIGRATION VERSION FOR serverpod +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('serverpod', '20260129180959368', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20260129180959368', "timestamp" = now(); + + +COMMIT; diff --git a/backend/sinalacs_server/migrations/migration_registry.txt b/backend/sinalacs_server/migrations/migration_registry.txt index 68791af..392cc14 100644 --- a/backend/sinalacs_server/migrations/migration_registry.txt +++ b/backend/sinalacs_server/migrations/migration_registry.txt @@ -33,3 +33,4 @@ 20261016135918697 20261016140159775 20261016155336452 +20261017171640323 diff --git a/backend/sinalacs_server/test/integration/device_wipe_store_test.dart b/backend/sinalacs_server/test/integration/device_wipe_store_test.dart new file mode 100644 index 0000000..f66e4a5 --- /dev/null +++ b/backend/sinalacs_server/test/integration/device_wipe_store_test.dart @@ -0,0 +1,187 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_device_wipe_store.dart'; +import 'package:test/test.dart'; + +import 'test_tools/serverpod_test_tools.dart'; + +/// Pedido de limpeza remota (#81) contra Postgres real: revogação por aparelho +/// na mesma transação, idempotência pelo índice único e o estado derivado dos +/// carimbos. Dados sintéticos. +const _u1 = '00000000-0000-4000-8000-00000000d001'; +const _u2 = '00000000-0000-4000-8000-00000000d002'; +const _u3 = '00000000-0000-4000-8000-00000000d003'; +const _admin = '00000000-0000-4000-8000-00000000d004'; + +const _aparelhoD = 'dddddddd-aparelho-d'; +const _aparelhoE = 'eeeeeeee-aparelho-e'; + +final _t = DateTime.utc(2026, 10, 17, 9); + +var _seq = 0; +UuidValue _id() => UuidValue.fromString( + '00000000-0000-4000-8000-${(0xd100 + (++_seq)).toRadixString(16).padLeft(12, '0')}', +); + +Future _usuario(Session s, String id, UserRole role) => User.db + .insertRow( + s, + User( + id: UuidValue.fromString(id), + cpfHash: 'wipe-$id', + name: 'Usuário sintético', + birthDate: DateTime.utc(1980), + role: role, + createdAt: _t, + updatedAt: _t, + ), + ) + .then((_) {}); + +Future _refresh(Session s, String user, String device, DateTime issued) => + AcsRefreshToken.db + .insertRow( + s, + AcsRefreshToken( + id: _id(), + userId: UuidValue.fromString(user), + familyId: _id(), + tokenHash: 'r-${_id()}', + deviceId: device, + issuedAt: issued, + idleExpiresAt: issued.add(const Duration(hours: 2)), + absoluteExpiresAt: issued.add(const Duration(hours: 8)), + ), + ) + .then((_) {}); + +Future _envio(Session s, String user, String device, DateTime issued) => + AcsUploadToken.db + .insertRow( + s, + AcsUploadToken( + id: _id(), + userId: UuidValue.fromString(user), + tokenHash: 'u-${_id()}', + deviceId: device, + issuedAt: issued, + expiresAt: issued.add(const Duration(days: 7)), + ), + ) + .then((_) {}); + +Future _vivos(Session s, String tabela, String device) async { + final r = await s.db.unsafeQuery( + 'SELECT count(*) FROM $tabela WHERE "deviceId" = @d AND "revokedAt" IS NULL', + parameters: QueryParameters.named({'d': device}), + ); + return r.single[0]! as int; +} + +void main() { + withServerpod('Dado o pedido de limpeza remota do aparelho (#81)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + late OrmDeviceWipeStore store; + setUp(() async { + session = sessionBuilder.build(); + store = OrmDeviceWipeStore(session: () => session); + await _usuario(session, _u1, UserRole.acs); + await _usuario(session, _u2, UserRole.acs); + await _usuario(session, _u3, UserRole.acs); + await _usuario(session, _admin, UserRole.admin); + // Aparelho D: dois donos (refresh + envio); aparelho E: outro usuário. + await _refresh(session, _u1, _aparelhoD, _t); + await _envio(session, _u1, _aparelhoD, _t.add(const Duration(hours: 1))); + await _refresh(session, _u2, _aparelhoD, _t); + await _envio(session, _u2, _aparelhoD, _t); + await _refresh(session, _u3, _aparelhoE, _t); + await _envio(session, _u3, _aparelhoE, _t); + }); + + Future<({DeviceWipeOrder order, bool created})> pedir({ + DateTime? at, + }) => store.orderWipe( + deviceId: _aparelhoD, + targetUserId: _u1, + requestedBy: _admin, + reason: DeviceWipeReason.lost, + at: at ?? _t.add(const Duration(days: 1)), + ); + + test( + 'orderWipe revoga refresh e envio de todos os donos do deviceId e só dele', + () async { + expect(await _vivos(session, 'acs_refresh_tokens', _aparelhoD), 2); + expect(await _vivos(session, 'acs_upload_tokens', _aparelhoD), 2); + + final r = await pedir(); + expect(r.created, isTrue); + expect(r.order.deviceId, _aparelhoD); + expect(r.order.reason, DeviceWipeReason.lost); + + expect(await _vivos(session, 'acs_refresh_tokens', _aparelhoD), 0); + expect(await _vivos(session, 'acs_upload_tokens', _aparelhoD), 0); + expect(await _vivos(session, 'acs_refresh_tokens', _aparelhoE), 1); + expect(await _vivos(session, 'acs_upload_tokens', _aparelhoE), 1); + }, + ); + + test('segundo orderWipe não cria linha nem revoga de novo', () async { + final primeiro = await pedir(); + // Token novo no mesmo aparelho depois do pedido: o segundo pedido não + // pode revogá-lo. + await _refresh(session, _u1, _aparelhoD, _t.add(const Duration(days: 2))); + final segundo = await pedir(at: _t.add(const Duration(days: 3))); + expect(segundo.created, isFalse); + expect(segundo.order.id, primeiro.order.id); + expect(segundo.order.requestedAt, primeiro.order.requestedAt); + expect(await _vivos(session, 'acs_refresh_tokens', _aparelhoD), 1); + final n = await DeviceWipeOrder.db.count( + session, + where: (t) => t.deviceId.equals(_aparelhoD), + ); + expect(n, 1); + }); + + test( + 'devicesOf agrupa por deviceId, lastSeenAt = maior issuedAt, junta estado do pedido', + () async { + var lista = await store.devicesOf(_u1); + expect(lista, hasLength(1)); + expect(lista.single.deviceId, _aparelhoD); + expect(lista.single.label, 'Aparelho dddddddd'); + expect(lista.single.lastSeenAt, _t.add(const Duration(hours: 1))); + expect(lista.single.wipeState, isNull); + + final pedido = await pedir(); + final id = pedido.order.id.toString(); + lista = await store.devicesOf(_u1); + expect(lista.single.wipeState, DeviceWipeState.pending); + expect(lista.single.wipeRequestedAt, pedido.order.requestedAt); + + expect(await store.markDelivered(id, _t.add(const Duration(days: 2))), isTrue); + expect(await store.markDelivered(id, _t.add(const Duration(days: 3))), isFalse); + lista = await store.devicesOf(_u1); + expect(lista.single.wipeState, DeviceWipeState.delivered); + + expect(await store.markConfirmed(_aparelhoE, id, _t), isFalse); + expect(await store.markConfirmed(_aparelhoD, id, _t.add(const Duration(days: 4))), isTrue); + expect(await store.markConfirmed(_aparelhoD, id, _t.add(const Duration(days: 5))), isFalse); + lista = await store.devicesOf(_u1); + expect(lista.single.wipeState, DeviceWipeState.confirmed); + expect(lista.single.wipeConfirmedAt, _t.add(const Duration(days: 4))); + expect((await store.orderFor(_aparelhoD))!.id.toString(), id); + expect(await store.orderFor(_aparelhoE), isNull); + }, + ); + + test('deviceBelongsTo só para quem tem token no aparelho', () async { + expect(await store.deviceBelongsTo(_u1, _aparelhoD), isTrue); + expect(await store.deviceBelongsTo(_u1, _aparelhoE), isFalse); + expect(await store.deviceBelongsTo('nao-uuid', _aparelhoD), isFalse); + }); + }); +} 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 21b4fa3..77d8607 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 @@ -41,52 +41,58 @@ import 'package:sinalacs_server/src/generated/api/admin_password_reset_result.da as _i19; import 'package:sinalacs_server/src/generated/api/admin_staff_mfa_reset_result.dart' as _i20; -import 'package:sinalacs_server/src/generated/api/admin_escalation_result.dart' +import 'package:sinalacs_server/src/generated/api/admin_acs_device.dart' as _i21; -import 'package:sinalacs_server/src/generated/enums/escalation_reason.dart' +import 'package:sinalacs_server/src/generated/api/admin_device_wipe_result.dart' as _i22; -import 'package:sinalacs_server/src/generated/api/notice_history_page.dart' +import 'package:sinalacs_server/src/generated/enums/device_wipe_reason.dart' as _i23; -import 'package:sinalacs_server/src/generated/api/terms_version_entry.dart' +import 'package:sinalacs_server/src/generated/api/admin_escalation_result.dart' as _i24; -import 'package:sinalacs_server/src/generated/api/red_alert_result.dart' +import 'package:sinalacs_server/src/generated/enums/escalation_reason.dart' as _i25; -import 'package:sinalacs_server/src/generated/api/alert_ack_result.dart' +import 'package:sinalacs_server/src/generated/api/notice_history_page.dart' as _i26; -import 'package:sinalacs_server/src/generated/api/alert_resolve_result.dart' +import 'package:sinalacs_server/src/generated/api/terms_version_entry.dart' as _i27; -import 'package:sinalacs_server/src/generated/api/acs_open_alert.dart' as _i28; -import 'package:sinalacs_server/src/generated/api/alert_status_result.dart' +import 'package:sinalacs_server/src/generated/api/red_alert_result.dart' + as _i28; +import 'package:sinalacs_server/src/generated/api/alert_ack_result.dart' as _i29; -import 'package:sinalacs_server/src/generated/api/development_login_result.dart' +import 'package:sinalacs_server/src/generated/api/alert_resolve_result.dart' as _i30; +import 'package:sinalacs_server/src/generated/api/acs_open_alert.dart' as _i31; +import 'package:sinalacs_server/src/generated/api/alert_status_result.dart' + as _i32; +import 'package:sinalacs_server/src/generated/api/development_login_result.dart' + as _i33; import 'package:sinalacs_server/src/generated/api/totp_enrollment_start.dart' - as _i31; -import 'package:sinalacs_server/src/generated/api/service_health.dart' as _i32; + as _i34; +import 'package:sinalacs_server/src/generated/api/service_health.dart' as _i35; import 'package:sinalacs_server/src/generated/api/notice_send_result.dart' - as _i33; + as _i36; import 'package:sinalacs_server/src/generated/api/enrollment_token_result.dart' - as _i34; + as _i37; import 'package:sinalacs_server/src/generated/api/enrollment_result.dart' - as _i35; + as _i38; import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' - as _i36; + as _i39; import 'package:sinalacs_server/src/generated/api/patient_data_overview.dart' - as _i37; + as _i40; import 'package:sinalacs_server/src/generated/api/patient_consent_record.dart' - as _i38; + as _i41; import 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' - as _i39; + as _i42; import 'package:sinalacs_server/src/generated/api/terms_change_notice.dart' - as _i40; + as _i43; import 'package:sinalacs_server/src/generated/api/patient_data_subject_request_record.dart' - as _i41; -import 'package:sinalacs_server/src/generated/api/triage_result.dart' as _i42; -import 'package:sinalacs_server/src/generated/api/ubs_contact.dart' as _i43; -import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' as _i44; +import 'package:sinalacs_server/src/generated/api/triage_result.dart' as _i45; +import 'package:sinalacs_server/src/generated/api/ubs_contact.dart' as _i46; +import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' + as _i47; import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' - as _i45; + as _i48; import 'package:sinalacs_server/src/generated/protocol.dart'; import 'package:sinalacs_server/src/generated/endpoints.dart'; export 'package:serverpod_test/serverpod_test_public_exports.dart'; @@ -1021,11 +1027,85 @@ class _AdminEndpoint { }); } - _i3.Future<_i21.AdminEscalationResult> escalateAlert( + _i3.Future> acsDevices( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String acsId, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'acsDevices', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'acsDevices', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'acsId': acsId, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i22.AdminDeviceWipeResult> orderDeviceWipe( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String acsId, + required String deviceId, + required _i23.DeviceWipeReason reason, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'orderDeviceWipe', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'orderDeviceWipe', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'acsId': acsId, + 'deviceId': deviceId, + 'reason': reason, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i22.AdminDeviceWipeResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i24.AdminEscalationResult> escalateAlert( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String alertId, - required _i22.EscalationReason reason, + required _i25.EscalationReason reason, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = @@ -1050,7 +1130,7 @@ class _AdminEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i21.AdminEscalationResult>); + as _i3.Future<_i24.AdminEscalationResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1136,7 +1216,7 @@ class _AdminEndpoint { }); } - _i3.Future<_i23.NoticeHistoryPage> noticeHistory( + _i3.Future<_i26.NoticeHistoryPage> noticeHistory( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, String? microAreaId, @@ -1167,7 +1247,7 @@ class _AdminEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i23.NoticeHistoryPage>); + as _i3.Future<_i26.NoticeHistoryPage>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1175,7 +1255,7 @@ class _AdminEndpoint { }); } - _i3.Future> termsVersions( + _i3.Future> termsVersions( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -1198,7 +1278,7 @@ class _AdminEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1206,7 +1286,7 @@ class _AdminEndpoint { }); } - _i3.Future<_i24.TermsVersionEntry> draftTermsVersion( + _i3.Future<_i27.TermsVersionEntry> draftTermsVersion( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String version, @@ -1237,7 +1317,7 @@ class _AdminEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i24.TermsVersionEntry>); + as _i3.Future<_i27.TermsVersionEntry>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1245,7 +1325,7 @@ class _AdminEndpoint { }); } - _i3.Future<_i24.TermsVersionEntry> markTermsReviewed( + _i3.Future<_i27.TermsVersionEntry> markTermsReviewed( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String id, @@ -1272,7 +1352,7 @@ class _AdminEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i24.TermsVersionEntry>); + as _i3.Future<_i27.TermsVersionEntry>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1280,7 +1360,7 @@ class _AdminEndpoint { }); } - _i3.Future<_i24.TermsVersionEntry> publishTermsVersion( + _i3.Future<_i27.TermsVersionEntry> publishTermsVersion( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String id, @@ -1307,7 +1387,7 @@ class _AdminEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i24.TermsVersionEntry>); + as _i3.Future<_i27.TermsVersionEntry>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1326,7 +1406,7 @@ class _AlertsEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i25.RedAlertResult> createRedAlert( + _i3.Future<_i28.RedAlertResult> createRedAlert( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String idempotencyKey, @@ -1357,7 +1437,7 @@ class _AlertsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i25.RedAlertResult>); + as _i3.Future<_i28.RedAlertResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1365,7 +1445,7 @@ class _AlertsEndpoint { }); } - _i3.Future<_i26.AlertAckResult> acknowledge( + _i3.Future<_i29.AlertAckResult> acknowledge( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String alertId, @@ -1392,7 +1472,7 @@ class _AlertsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i26.AlertAckResult>); + as _i3.Future<_i29.AlertAckResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1400,7 +1480,7 @@ class _AlertsEndpoint { }); } - _i3.Future<_i27.AlertResolveResult> resolve( + _i3.Future<_i30.AlertResolveResult> resolve( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String alertId, @@ -1427,7 +1507,7 @@ class _AlertsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i27.AlertResolveResult>); + as _i3.Future<_i30.AlertResolveResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1435,7 +1515,7 @@ class _AlertsEndpoint { }); } - _i3.Future> openForMicroArea( + _i3.Future> openForMicroArea( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -1458,7 +1538,7 @@ class _AlertsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1466,7 +1546,7 @@ class _AlertsEndpoint { }); } - _i3.Future<_i29.AlertStatusResult> statusFor( + _i3.Future<_i32.AlertStatusResult> statusFor( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -1489,7 +1569,7 @@ class _AlertsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i29.AlertStatusResult>); + as _i3.Future<_i32.AlertStatusResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1508,7 +1588,7 @@ class _AuthEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i30.DevelopmentLoginResult> developmentLogin( + _i3.Future<_i33.DevelopmentLoginResult> developmentLogin( _i1.TestSessionBuilder sessionBuilder, { required String role, }) async { @@ -1531,7 +1611,7 @@ class _AuthEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i30.DevelopmentLoginResult>); + as _i3.Future<_i33.DevelopmentLoginResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1539,7 +1619,7 @@ class _AuthEndpoint { }); } - _i3.Future<_i30.DevelopmentLoginResult> loginInstitutional( + _i3.Future<_i33.DevelopmentLoginResult> loginInstitutional( _i1.TestSessionBuilder sessionBuilder, { required String matricula, required String password, @@ -1570,7 +1650,7 @@ class _AuthEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i30.DevelopmentLoginResult>); + as _i3.Future<_i33.DevelopmentLoginResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1578,7 +1658,7 @@ class _AuthEndpoint { }); } - _i3.Future<_i30.DevelopmentLoginResult> loginStaff( + _i3.Future<_i33.DevelopmentLoginResult> loginStaff( _i1.TestSessionBuilder sessionBuilder, { required String matricula, required String password, @@ -1607,7 +1687,7 @@ class _AuthEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i30.DevelopmentLoginResult>); + as _i3.Future<_i33.DevelopmentLoginResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1615,7 +1695,7 @@ class _AuthEndpoint { }); } - _i3.Future<_i31.TotpEnrollmentStart> beginStaffTotpEnrollment( + _i3.Future<_i34.TotpEnrollmentStart> beginStaffTotpEnrollment( _i1.TestSessionBuilder sessionBuilder, { required String matricula, required String password, @@ -1644,7 +1724,7 @@ class _AuthEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i31.TotpEnrollmentStart>); + as _i3.Future<_i34.TotpEnrollmentStart>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1691,7 +1771,7 @@ class _AuthEndpoint { }); } - _i3.Future<_i30.DevelopmentLoginResult> refreshSession( + _i3.Future<_i33.DevelopmentLoginResult> refreshSession( _i1.TestSessionBuilder sessionBuilder, { required String refreshToken, required String deviceId, @@ -1718,7 +1798,7 @@ class _AuthEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i30.DevelopmentLoginResult>); + as _i3.Future<_i33.DevelopmentLoginResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1757,7 +1837,7 @@ class _AuthEndpoint { }); } - _i3.Future<_i31.TotpEnrollmentStart> beginTotpEnrollment( + _i3.Future<_i34.TotpEnrollmentStart> beginTotpEnrollment( _i1.TestSessionBuilder sessionBuilder, { required String matricula, required String password, @@ -1784,7 +1864,7 @@ class _AuthEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i31.TotpEnrollmentStart>); + as _i3.Future<_i34.TotpEnrollmentStart>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1864,7 +1944,7 @@ class _AuthEndpoint { }); } - _i3.Future<_i30.DevelopmentLoginResult> verifyOtp( + _i3.Future<_i33.DevelopmentLoginResult> verifyOtp( _i1.TestSessionBuilder sessionBuilder, { required String cpf, required String code, @@ -1893,7 +1973,7 @@ class _AuthEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i30.DevelopmentLoginResult>); + as _i3.Future<_i33.DevelopmentLoginResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1960,7 +2040,7 @@ class _HealthEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i32.ServiceHealth> check( + _i3.Future<_i35.ServiceHealth> check( _i1.TestSessionBuilder sessionBuilder, ) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { @@ -1982,7 +2062,7 @@ class _HealthEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i32.ServiceHealth>); + as _i3.Future<_i35.ServiceHealth>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2001,7 +2081,7 @@ class _NoticesEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i33.NoticeSendResult> sendSegmented( + _i3.Future<_i36.NoticeSendResult> sendSegmented( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String title, @@ -2032,7 +2112,7 @@ class _NoticesEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i33.NoticeSendResult>); + as _i3.Future<_i36.NoticeSendResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2051,7 +2131,7 @@ class _OnboardingEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i34.EnrollmentTokenResult> generateEnrollmentToken( + _i3.Future<_i37.EnrollmentTokenResult> generateEnrollmentToken( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String patientId, @@ -2078,7 +2158,7 @@ class _OnboardingEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i34.EnrollmentTokenResult>); + as _i3.Future<_i37.EnrollmentTokenResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2086,7 +2166,7 @@ class _OnboardingEndpoint { }); } - _i3.Future<_i35.EnrollmentResult> completeEnrollment( + _i3.Future<_i38.EnrollmentResult> completeEnrollment( _i1.TestSessionBuilder sessionBuilder, { required String token, required bool healthDataConsent, @@ -2119,7 +2199,7 @@ class _OnboardingEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i35.EnrollmentResult>); + as _i3.Future<_i38.EnrollmentResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2138,7 +2218,7 @@ class _PatientsEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future> listMicroArea( + _i3.Future> listMicroArea( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -2161,7 +2241,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2235,7 +2315,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i37.PatientDataOverview> myData( + _i3.Future<_i40.PatientDataOverview> myData( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -2258,7 +2338,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i37.PatientDataOverview>); + as _i3.Future<_i40.PatientDataOverview>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2266,10 +2346,10 @@ class _PatientsEndpoint { }); } - _i3.Future<_i38.PatientConsentRecord> updateConsent( + _i3.Future<_i41.PatientConsentRecord> updateConsent( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required _i39.ConsentPurpose purpose, + required _i42.ConsentPurpose purpose, required bool granted, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { @@ -2295,7 +2375,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i38.PatientConsentRecord>); + as _i3.Future<_i41.PatientConsentRecord>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2303,7 +2383,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i38.PatientConsentRecord> acceptTermsOfUse( + _i3.Future<_i41.PatientConsentRecord> acceptTermsOfUse( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -2326,7 +2406,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i38.PatientConsentRecord>); + as _i3.Future<_i41.PatientConsentRecord>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2368,7 +2448,7 @@ class _PatientsEndpoint { _i3.Future hasGrantedConsent( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required _i39.ConsentPurpose purpose, + required _i42.ConsentPurpose purpose, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = @@ -2400,7 +2480,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i40.TermsChangeNotice?> termsChangeNotice( + _i3.Future<_i43.TermsChangeNotice?> termsChangeNotice( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -2423,7 +2503,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i40.TermsChangeNotice?>); + as _i3.Future<_i43.TermsChangeNotice?>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2431,7 +2511,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i41.PatientDataSubjectRequestRecord> requestDataDeletion( + _i3.Future<_i44.PatientDataSubjectRequestRecord> requestDataDeletion( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -2454,7 +2534,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i41.PatientDataSubjectRequestRecord>); + as _i3.Future<_i44.PatientDataSubjectRequestRecord>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2462,7 +2542,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i41.PatientDataSubjectRequestRecord> requestDataCorrection( + _i3.Future<_i44.PatientDataSubjectRequestRecord> requestDataCorrection( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String details, @@ -2489,7 +2569,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i41.PatientDataSubjectRequestRecord>); + as _i3.Future<_i44.PatientDataSubjectRequestRecord>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2508,7 +2588,7 @@ class _TriageEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i42.TriageResult> evaluate( + _i3.Future<_i45.TriageResult> evaluate( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required bool chestPain, @@ -2545,7 +2625,7 @@ class _TriageEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i42.TriageResult>); + as _i3.Future<_i45.TriageResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2564,7 +2644,7 @@ class _UbsEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i43.UbsContact> myContact( + _i3.Future<_i46.UbsContact> myContact( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -2587,7 +2667,7 @@ class _UbsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i43.UbsContact>); + as _i3.Future<_i46.UbsContact>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2606,10 +2686,10 @@ class _VisitsEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future> sync( + _i3.Future> sync( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required List<_i45.VisitSyncEntry> visits, + required List<_i48.VisitSyncEntry> visits, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = @@ -2633,7 +2713,7 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2641,10 +2721,10 @@ class _VisitsEndpoint { }); } - _i3.Future<_i44.VisitSyncResult> resolveConflict( + _i3.Future<_i47.VisitSyncResult> resolveConflict( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required _i45.VisitSyncEntry visit, + required _i48.VisitSyncEntry visit, required int expectedServerVersion, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { @@ -2670,7 +2750,7 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i44.VisitSyncResult>); + as _i3.Future<_i47.VisitSyncResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2678,11 +2758,11 @@ class _VisitsEndpoint { }); } - _i3.Future> syncLegacy( + _i3.Future> syncLegacy( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String deviceId, - required List<_i45.VisitSyncEntry> visits, + required List<_i48.VisitSyncEntry> visits, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = @@ -2707,7 +2787,7 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2715,11 +2795,11 @@ class _VisitsEndpoint { }); } - _i3.Future> syncDeferred( + _i3.Future> syncDeferred( _i1.TestSessionBuilder sessionBuilder, { required String uploadToken, required String deviceId, - required List<_i45.VisitSyncEntry> visits, + required List<_i48.VisitSyncEntry> visits, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = @@ -2744,7 +2824,7 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -2783,7 +2863,7 @@ class _VisitsEndpoint { }); } - _i3.Future> pull( + _i3.Future> pull( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required DateTime since, @@ -2810,7 +2890,7 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); diff --git a/backend/sinalacs_server/test/unit/device_wipe_service_test.dart b/backend/sinalacs_server/test/unit/device_wipe_service_test.dart new file mode 100644 index 0000000..a84361e --- /dev/null +++ b/backend/sinalacs_server/test/unit/device_wipe_service_test.dart @@ -0,0 +1,285 @@ +import 'package:serverpod/serverpod.dart' show UuidValue; +import 'package:sinalacs_server/src/application/admin/admin_account_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/devices/device_wipe_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +/// Pedido de limpeza remota e listagem de aparelhos do ACS (#81) sem banco: +/// papel, escopo por UBS, recusa única e auditoria. Dados sintéticos. +final _ordem = []; + +/// Só a parte do store de contas que o serviço usa (`acsById`, `ubsOf`). +class _Contas implements AdminAccountStore { + final acs = { + 'acs-a': AdminAcs( + id: 'acs-a', + name: 'Ana ACS', + enrollmentId: 'ACS-1', + ubsId: 'ubs-a', + ubsName: 'UBS A', + microAreaId: 'ma-a', + microAreaName: 'Microárea A', + active: true, + mfaActive: false, + ), + 'acs-b': AdminAcs( + id: 'acs-b', + name: 'Bia ACS', + enrollmentId: 'ACS-2', + ubsId: 'ubs-b', + ubsName: 'UBS B', + microAreaId: 'ma-b', + microAreaName: 'Microárea B', + active: true, + mfaActive: false, + ), + }; + + @override + Future acsById(AdminScope scope, String acsId) async { + _ordem.add('contas:acsById'); + final a = acs[acsId]; + if (a == null) return null; + if (scope.ubsId != null && a.ubsId != scope.ubsId) return null; + return a; + } + + @override + Future ubsOf(String staffId) async => + staffId == 'coord-a' ? 'ubs-a' : null; + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('${invocation.memberName} não é usado aqui'); +} + +class _Aparelhos implements DeviceWipeStore { + /// Aparelhos de cada ACS (com token), por id. + final donos = >{ + 'acs-a': {'aaaaaaaa-1111', InstitutionalAuthService.deviceIdAbsent}, + 'acs-b': {'bbbbbbbb-2222'}, + }; + DeviceWipeOrder? lastOrder; + int orderWipeCalls = 0; + + @override + Future> devicesOf(String acsId) async { + _ordem.add('store:devicesOf'); + return [ + for (final d in donos[acsId] ?? {}) + AdminAcsDevice( + deviceId: d, + label: 'Aparelho ${d.substring(0, 8)}', + lastSeenAt: DateTime.utc(2026, 10, 1), + ), + ]; + } + + @override + Future deviceBelongsTo(String acsId, String deviceId) async => + (donos[acsId] ?? {}).contains(deviceId); + + @override + Future<({DeviceWipeOrder order, bool created})> orderWipe({ + required String deviceId, + required String targetUserId, + required String requestedBy, + required DeviceWipeReason reason, + required DateTime at, + }) async { + orderWipeCalls++; + final existente = lastOrder; + if (existente != null && existente.deviceId == deviceId) { + return (order: existente, created: false); + } + final nova = DeviceWipeOrder( + id: UuidValue.fromString('00000000-0000-4000-8000-00000000c001'), + deviceId: deviceId, + targetUserId: UuidValue.fromString('00000000-0000-4000-8000-00000000c002'), + requestedBy: UuidValue.fromString('00000000-0000-4000-8000-00000000c003'), + reason: reason, + requestedAt: at, + ); + lastOrder = nova; + return (order: nova, created: true); + } + + @override + Future orderFor(String deviceId) => + throw UnimplementedError(); + + @override + Future markDelivered(String orderId, DateTime at) => + throw UnimplementedError(); + + @override + Future markConfirmed(String deviceId, String orderId, DateTime at) => + throw UnimplementedError(); +} + +class _Audit extends AuditTrail { + final events = []; + + @override + Future record(AuditEvent event) async { + _ordem.add('audit:${event.actionType}:${event.resourceType}:${event.result}'); + events.add(event); + } +} + +AuthenticatedUser _u(String id, UserRole role) => + AuthenticatedUser(id: id, role: role, microAreaId: null, deviceId: 'x'); + +void main() { + late _Contas contas; + late _Aparelhos aparelhos; + late _Audit audit; + late DeviceWipeService servico; + var agora = DateTime.utc(2026, 10, 17, 12); + final admin = _u('admin-1', UserRole.admin); + final coordA = _u('coord-a', UserRole.coordinator); + + setUp(() { + _ordem.clear(); + agora = DateTime.utc(2026, 10, 17, 12); + contas = _Contas(); + aparelhos = _Aparelhos(); + audit = _Audit(); + servico = DeviceWipeService( + store: aparelhos, + accounts: contas, + audit: audit, + clock: () => agora, + ); + }); + + test('admin pede limpeza: created, revoga e audita wipe_ordered', () async { + final r = await servico.orderWipe( + admin, + acsId: 'acs-b', + deviceId: 'bbbbbbbb-2222', + reason: DeviceWipeReason.lost, + ); + expect(r.alreadyOrdered, isFalse); + expect(r.orderId, aparelhos.lastOrder!.id.toString()); + expect(r.requestedAt, agora); + expect(aparelhos.orderWipeCalls, 1); + final e = audit.events.single; + expect(e.actionType, 'write'); + expect(e.resourceType, 'admin_device'); + expect(e.result, 'wipe_ordered'); + expect(e.resourceId, r.orderId); + }); + + test( + 'pedido repetido devolve alreadyOrdered com o requestedAt original e audita already_ordered', + () async { + final primeiro = await servico.orderWipe( + coordA, + acsId: 'acs-a', + deviceId: 'aaaaaaaa-1111', + reason: DeviceWipeReason.stolen, + ); + agora = agora.add(const Duration(hours: 3)); + final segundo = await servico.orderWipe( + coordA, + acsId: 'acs-a', + deviceId: 'aaaaaaaa-1111', + reason: DeviceWipeReason.lost, + ); + expect(segundo.alreadyOrdered, isTrue); + expect(segundo.orderId, primeiro.orderId); + expect(segundo.requestedAt, primeiro.requestedAt); + expect(audit.events.map((e) => e.result), [ + 'wipe_ordered', + 'already_ordered', + ]); + expect(audit.events.last.resourceId, primeiro.orderId); + }, + ); + + test( + 'coordenador fora da UBS e aparelho alheio recebem a mesma recusa sem revogar nada', + () async { + final casos = <(String, String)>[ + ('acs-b', 'bbbbbbbb-2222'), // ACS de outra UBS + ('acs-a', 'zzzzzzzz-9999'), // aparelho sem token desse ACS + ('acs-a', InstitutionalAuthService.deviceIdAbsent), // sentinela + ('acs-inexistente', 'aaaaaaaa-1111'), // ACS que não existe + ]; + for (final (acs, dev) in casos) { + audit.events.clear(); + await expectLater( + servico.orderWipe( + coordA, + acsId: acs, + deviceId: dev, + reason: DeviceWipeReason.lost, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Aparelho não encontrado.', + ), + ), + reason: '$acs/$dev', + ); + expect(audit.events.single.result, 'denied'); + expect(audit.events.single.actionType, 'write'); + expect(aparelhos.orderWipeCalls, 0); + } + }, + ); + + test('paciente e ACS não alcançam', () async { + for (final role in [UserRole.patient, UserRole.acs]) { + final u = _u('u-${role.name}', role); + await expectLater( + servico.orderWipe( + u, + acsId: 'acs-a', + deviceId: 'aaaaaaaa-1111', + reason: DeviceWipeReason.lost, + ), + throwsA(isA()), + ); + await expectLater( + servico.devicesOf(u, acsId: 'acs-a'), + throwsA(isA()), + ); + } + expect(aparelhos.orderWipeCalls, 0); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }); + + test( + 'listagem audita read admin_acs_devices antes de ler e esconde o sentinela', + () async { + final lista = await servico.devicesOf(coordA, acsId: 'acs-a'); + expect(lista.map((d) => d.deviceId), ['aaaaaaaa-1111']); + expect( + _ordem.indexOf('audit:read:admin_acs_devices:success'), + lessThan(_ordem.indexOf('store:devicesOf')), + ); + }, + ); + + test('listagem de ACS de outra UBS recebe a recusa única', () async { + await expectLater( + servico.devicesOf(coordA, acsId: 'acs-b'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Aparelho não encontrado.', + ), + ), + ); + expect(audit.events.single.result, 'denied'); + expect(_ordem, isNot(contains('store:devicesOf'))); + }); +} From 8dbce321767d8df54b34a528e8ab940df083ced5 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 13:30:32 -0400 Subject: [PATCH 03/17] =?UTF-8?q?feat(backend):=20aparelho=20recebe=20e=20?= =?UTF-8?q?confirma=20a=20limpeza=20remota;=20login=20e=20renova=C3=A7?= =?UTF-8?q?=C3=A3o=20recusam=20aparelho=20marcado=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/src/protocol/client.dart | 24 + .../device_wipe_required_exception.dart | 88 ++++ .../lib/src/protocol/protocol.dart | 391 ++++++++-------- .../devices/device_wipe_service.dart | 59 +++ .../lib/src/endpoints/auth_endpoint.dart | 11 + .../lib/src/endpoints/devices_endpoint.dart | 18 + .../lib/src/endpoints/visits_endpoint.dart | 5 + .../lib/src/generated/endpoints.dart | 44 ++ .../device_wipe_required_exception.dart | 100 ++++ .../lib/src/generated/protocol.dart | 439 +++++++++--------- .../lib/src/generated/protocol.yaml | 2 + .../device_wipe_required_exception.spy.yaml | 7 + .../device_wipe_endpoint_test.dart | 237 ++++++++++ .../test_tools/serverpod_test_tools.dart | 66 +++ .../test/unit/device_wipe_service_test.dart | 145 +++++- .../test/unit/endpoint_auth_posture_test.dart | 8 + 16 files changed, 1238 insertions(+), 406 deletions(-) create mode 100644 backend/sinalacs_client/lib/src/protocol/exceptions/device_wipe_required_exception.dart create mode 100644 backend/sinalacs_server/lib/src/generated/exceptions/device_wipe_required_exception.dart create mode 100644 backend/sinalacs_server/lib/src/models/exceptions/device_wipe_required_exception.spy.yaml create mode 100644 backend/sinalacs_server/test/integration/device_wipe_endpoint_test.dart diff --git a/backend/sinalacs_client/lib/src/protocol/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index ebb2b0a..750977f 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -933,6 +933,30 @@ class EndpointDevices extends EndpointAuthenticated { 'platform': platform, }, ); + + /// O `orderId` do pedido de limpeza remota do aparelho, ou `null` (#81). + /// Público: o aparelho cujo token foi revogado precisa perguntar. Não devolve + /// identidade; só o id do pedido, que é um UUID aleatório. + _i2.Future pendingWipe(String deviceId) => + caller.callServerEndpoint( + 'devices', + 'pendingWipe', + {'deviceId': deviceId}, + ); + + /// O aparelho confirma que apagou tudo (#81). Sem token e sem retorno: um par + /// errado ou repetido se comporta igual (no-op), então não vira oráculo. + _i2.Future confirmWipe( + String deviceId, + String orderId, + ) => caller.callServerEndpoint( + 'devices', + 'confirmWipe', + { + 'deviceId': deviceId, + 'orderId': orderId, + }, + ); } /// Sonda de saúde. diff --git a/backend/sinalacs_client/lib/src/protocol/exceptions/device_wipe_required_exception.dart b/backend/sinalacs_client/lib/src/protocol/exceptions/device_wipe_required_exception.dart new file mode 100644 index 0000000..a8c9459 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/exceptions/device_wipe_required_exception.dart @@ -0,0 +1,88 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod_client/serverpod_client.dart' as _i1; + +/// O aparelho tem um pedido de limpeza remota da coordenação (#81). Login, +/// renovação e envio diferido o recusam com isto, para o app distinguir "limpar +/// o aparelho" de "sessão expirada". +abstract class DeviceWipeRequiredException + implements _i1.SerializableException, _i1.SerializableModel { + DeviceWipeRequiredException._({ + required this.orderId, + required this.message, + }); + + factory DeviceWipeRequiredException({ + required String orderId, + required String message, + }) = _DeviceWipeRequiredExceptionImpl; + + factory DeviceWipeRequiredException.fromJson( + Map jsonSerialization, + ) { + return DeviceWipeRequiredException( + orderId: jsonSerialization['orderId'] as String, + message: jsonSerialization['message'] as String, + ); + } + + String orderId; + + String message; + + /// Returns a shallow copy of this [DeviceWipeRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + DeviceWipeRequiredException copyWith({ + String? orderId, + String? message, + }); + @override + Map toJson() { + return { + '__className__': 'DeviceWipeRequiredException', + 'orderId': orderId, + 'message': message, + }; + } + + @override + String toString() { + return 'DeviceWipeRequiredException(orderId: $orderId, message: $message)'; + } +} + +class _DeviceWipeRequiredExceptionImpl extends DeviceWipeRequiredException { + _DeviceWipeRequiredExceptionImpl({ + required String orderId, + required String message, + }) : super._( + orderId: orderId, + message: message, + ); + + /// Returns a shallow copy of this [DeviceWipeRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + DeviceWipeRequiredException copyWith({ + String? orderId, + String? message, + }) { + return DeviceWipeRequiredException( + orderId: orderId ?? this.orderId, + message: message ?? this.message, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/protocol.dart b/backend/sinalacs_client/lib/src/protocol/protocol.dart index 3d844d8..732e393 100644 --- a/backend/sinalacs_client/lib/src/protocol/protocol.dart +++ b/backend/sinalacs_client/lib/src/protocol/protocol.dart @@ -93,41 +93,42 @@ import 'exceptions/alert_permission_exception.dart' as _i79; import 'exceptions/alert_validation_exception.dart' as _i80; import 'exceptions/authentication_failed_exception.dart' as _i81; import 'exceptions/data_rights_exception.dart' as _i82; -import 'exceptions/endpoint_disabled_exception.dart' as _i83; -import 'exceptions/enrollment_exception.dart' as _i84; -import 'exceptions/mfa_enrollment_required_exception.dart' as _i85; -import 'exceptions/mfa_required_exception.dart' as _i86; -import 'exceptions/notice_delivery_exception.dart' as _i87; -import 'exceptions/otp_request_exception.dart' as _i88; -import 'exceptions/session_expired_exception.dart' as _i89; -import 'micro_area.dart' as _i90; -import 'otp_challenge.dart' as _i91; -import 'patient.dart' as _i92; -import 'push_token.dart' as _i93; -import 'scheduled_notice.dart' as _i94; -import 'staff_account.dart' as _i95; -import 'terms_version.dart' as _i96; -import 'triage_answer.dart' as _i97; -import 'triage_session.dart' as _i98; -import 'ubs.dart' as _i99; -import 'user.dart' as _i100; -import 'user_credential.dart' as _i101; -import 'visit.dart' as _i102; +import 'exceptions/device_wipe_required_exception.dart' as _i83; +import 'exceptions/endpoint_disabled_exception.dart' as _i84; +import 'exceptions/enrollment_exception.dart' as _i85; +import 'exceptions/mfa_enrollment_required_exception.dart' as _i86; +import 'exceptions/mfa_required_exception.dart' as _i87; +import 'exceptions/notice_delivery_exception.dart' as _i88; +import 'exceptions/otp_request_exception.dart' as _i89; +import 'exceptions/session_expired_exception.dart' as _i90; +import 'micro_area.dart' as _i91; +import 'otp_challenge.dart' as _i92; +import 'patient.dart' as _i93; +import 'push_token.dart' as _i94; +import 'scheduled_notice.dart' as _i95; +import 'staff_account.dart' as _i96; +import 'terms_version.dart' as _i97; +import 'triage_answer.dart' as _i98; +import 'triage_session.dart' as _i99; +import 'ubs.dart' as _i100; +import 'user.dart' as _i101; +import 'user_credential.dart' as _i102; +import 'visit.dart' as _i103; import 'package:sinalacs_client/src/protocol/api/admin_micro_area.dart' - as _i103; -import 'package:sinalacs_client/src/protocol/api/admin_acs.dart' as _i104; -import 'package:sinalacs_client/src/protocol/api/admin_staff.dart' as _i105; + as _i104; +import 'package:sinalacs_client/src/protocol/api/admin_acs.dart' as _i105; +import 'package:sinalacs_client/src/protocol/api/admin_staff.dart' as _i106; import 'package:sinalacs_client/src/protocol/api/admin_acs_device.dart' - as _i106; -import 'package:sinalacs_client/src/protocol/api/terms_version_entry.dart' as _i107; -import 'package:sinalacs_client/src/protocol/api/acs_open_alert.dart' as _i108; +import 'package:sinalacs_client/src/protocol/api/terms_version_entry.dart' + as _i108; +import 'package:sinalacs_client/src/protocol/api/acs_open_alert.dart' as _i109; import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' - as _i109; -import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' as _i110; -import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' +import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' as _i111; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' + as _i112; export 'acs.dart'; export 'acs_refresh_token.dart'; export 'acs_upload_token.dart'; @@ -209,6 +210,7 @@ export 'exceptions/alert_permission_exception.dart'; export 'exceptions/alert_validation_exception.dart'; export 'exceptions/authentication_failed_exception.dart'; export 'exceptions/data_rights_exception.dart'; +export 'exceptions/device_wipe_required_exception.dart'; export 'exceptions/endpoint_disabled_exception.dart'; export 'exceptions/enrollment_exception.dart'; export 'exceptions/mfa_enrollment_required_exception.dart'; @@ -508,65 +510,68 @@ class Protocol extends _i1.SerializationManager { if (t == _i82.DataRightsException) { return _i82.DataRightsException.fromJson(data) as T; } - if (t == _i83.EndpointDisabledException) { - return _i83.EndpointDisabledException.fromJson(data) as T; + if (t == _i83.DeviceWipeRequiredException) { + return _i83.DeviceWipeRequiredException.fromJson(data) as T; } - if (t == _i84.EnrollmentException) { - return _i84.EnrollmentException.fromJson(data) as T; + if (t == _i84.EndpointDisabledException) { + return _i84.EndpointDisabledException.fromJson(data) as T; } - if (t == _i85.MfaEnrollmentRequiredException) { - return _i85.MfaEnrollmentRequiredException.fromJson(data) as T; + if (t == _i85.EnrollmentException) { + return _i85.EnrollmentException.fromJson(data) as T; } - if (t == _i86.MfaRequiredException) { - return _i86.MfaRequiredException.fromJson(data) as T; + if (t == _i86.MfaEnrollmentRequiredException) { + return _i86.MfaEnrollmentRequiredException.fromJson(data) as T; } - if (t == _i87.NoticeDeliveryException) { - return _i87.NoticeDeliveryException.fromJson(data) as T; + if (t == _i87.MfaRequiredException) { + return _i87.MfaRequiredException.fromJson(data) as T; } - if (t == _i88.OtpRequestException) { - return _i88.OtpRequestException.fromJson(data) as T; + if (t == _i88.NoticeDeliveryException) { + return _i88.NoticeDeliveryException.fromJson(data) as T; } - if (t == _i89.SessionExpiredException) { - return _i89.SessionExpiredException.fromJson(data) as T; + if (t == _i89.OtpRequestException) { + return _i89.OtpRequestException.fromJson(data) as T; } - if (t == _i90.MicroArea) { - return _i90.MicroArea.fromJson(data) as T; + if (t == _i90.SessionExpiredException) { + return _i90.SessionExpiredException.fromJson(data) as T; } - if (t == _i91.OtpChallenge) { - return _i91.OtpChallenge.fromJson(data) as T; + if (t == _i91.MicroArea) { + return _i91.MicroArea.fromJson(data) as T; } - if (t == _i92.Patient) { - return _i92.Patient.fromJson(data) as T; + if (t == _i92.OtpChallenge) { + return _i92.OtpChallenge.fromJson(data) as T; } - if (t == _i93.PushToken) { - return _i93.PushToken.fromJson(data) as T; + if (t == _i93.Patient) { + return _i93.Patient.fromJson(data) as T; } - if (t == _i94.ScheduledNotice) { - return _i94.ScheduledNotice.fromJson(data) as T; + if (t == _i94.PushToken) { + return _i94.PushToken.fromJson(data) as T; } - if (t == _i95.StaffAccount) { - return _i95.StaffAccount.fromJson(data) as T; + if (t == _i95.ScheduledNotice) { + return _i95.ScheduledNotice.fromJson(data) as T; } - if (t == _i96.TermsVersion) { - return _i96.TermsVersion.fromJson(data) as T; + if (t == _i96.StaffAccount) { + return _i96.StaffAccount.fromJson(data) as T; } - if (t == _i97.TriageAnswer) { - return _i97.TriageAnswer.fromJson(data) as T; + if (t == _i97.TermsVersion) { + return _i97.TermsVersion.fromJson(data) as T; } - if (t == _i98.TriageSession) { - return _i98.TriageSession.fromJson(data) as T; + if (t == _i98.TriageAnswer) { + return _i98.TriageAnswer.fromJson(data) as T; } - if (t == _i99.Ubs) { - return _i99.Ubs.fromJson(data) as T; + if (t == _i99.TriageSession) { + return _i99.TriageSession.fromJson(data) as T; } - if (t == _i100.User) { - return _i100.User.fromJson(data) as T; + if (t == _i100.Ubs) { + return _i100.Ubs.fromJson(data) as T; } - if (t == _i101.UserCredential) { - return _i101.UserCredential.fromJson(data) as T; + if (t == _i101.User) { + return _i101.User.fromJson(data) as T; } - if (t == _i102.Visit) { - return _i102.Visit.fromJson(data) as T; + if (t == _i102.UserCredential) { + return _i102.UserCredential.fromJson(data) as T; + } + if (t == _i103.Visit) { + return _i103.Visit.fromJson(data) as T; } if (t == _i1.getType<_i2.Acs?>()) { return (data != null ? _i2.Acs.fromJson(data) : null) as T; @@ -864,76 +869,82 @@ class Protocol extends _i1.SerializationManager { return (data != null ? _i82.DataRightsException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i83.EndpointDisabledException?>()) { + if (t == _i1.getType<_i83.DeviceWipeRequiredException?>()) { + return (data != null + ? _i83.DeviceWipeRequiredException.fromJson(data) + : null) + as T; + } + if (t == _i1.getType<_i84.EndpointDisabledException?>()) { return (data != null - ? _i83.EndpointDisabledException.fromJson(data) + ? _i84.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i84.EnrollmentException?>()) { - return (data != null ? _i84.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i85.EnrollmentException?>()) { + return (data != null ? _i85.EnrollmentException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i85.MfaEnrollmentRequiredException?>()) { + if (t == _i1.getType<_i86.MfaEnrollmentRequiredException?>()) { return (data != null - ? _i85.MfaEnrollmentRequiredException.fromJson(data) + ? _i86.MfaEnrollmentRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i86.MfaRequiredException?>()) { - return (data != null ? _i86.MfaRequiredException.fromJson(data) : null) + if (t == _i1.getType<_i87.MfaRequiredException?>()) { + return (data != null ? _i87.MfaRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i87.NoticeDeliveryException?>()) { - return (data != null ? _i87.NoticeDeliveryException.fromJson(data) : null) + if (t == _i1.getType<_i88.NoticeDeliveryException?>()) { + return (data != null ? _i88.NoticeDeliveryException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i88.OtpRequestException?>()) { - return (data != null ? _i88.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i89.OtpRequestException?>()) { + return (data != null ? _i89.OtpRequestException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i89.SessionExpiredException?>()) { - return (data != null ? _i89.SessionExpiredException.fromJson(data) : null) + if (t == _i1.getType<_i90.SessionExpiredException?>()) { + return (data != null ? _i90.SessionExpiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i90.MicroArea?>()) { - return (data != null ? _i90.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i91.MicroArea?>()) { + return (data != null ? _i91.MicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i91.OtpChallenge?>()) { - return (data != null ? _i91.OtpChallenge.fromJson(data) : null) as T; + if (t == _i1.getType<_i92.OtpChallenge?>()) { + return (data != null ? _i92.OtpChallenge.fromJson(data) : null) as T; } - if (t == _i1.getType<_i92.Patient?>()) { - return (data != null ? _i92.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i93.Patient?>()) { + return (data != null ? _i93.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i93.PushToken?>()) { - return (data != null ? _i93.PushToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i94.PushToken?>()) { + return (data != null ? _i94.PushToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i94.ScheduledNotice?>()) { - return (data != null ? _i94.ScheduledNotice.fromJson(data) : null) as T; + if (t == _i1.getType<_i95.ScheduledNotice?>()) { + return (data != null ? _i95.ScheduledNotice.fromJson(data) : null) as T; } - if (t == _i1.getType<_i95.StaffAccount?>()) { - return (data != null ? _i95.StaffAccount.fromJson(data) : null) as T; + if (t == _i1.getType<_i96.StaffAccount?>()) { + return (data != null ? _i96.StaffAccount.fromJson(data) : null) as T; } - if (t == _i1.getType<_i96.TermsVersion?>()) { - return (data != null ? _i96.TermsVersion.fromJson(data) : null) as T; + if (t == _i1.getType<_i97.TermsVersion?>()) { + return (data != null ? _i97.TermsVersion.fromJson(data) : null) as T; } - if (t == _i1.getType<_i97.TriageAnswer?>()) { - return (data != null ? _i97.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i98.TriageAnswer?>()) { + return (data != null ? _i98.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i98.TriageSession?>()) { - return (data != null ? _i98.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i99.TriageSession?>()) { + return (data != null ? _i99.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i99.Ubs?>()) { - return (data != null ? _i99.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i100.Ubs?>()) { + return (data != null ? _i100.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i100.User?>()) { - return (data != null ? _i100.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i101.User?>()) { + return (data != null ? _i101.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i101.UserCredential?>()) { - return (data != null ? _i101.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i102.UserCredential?>()) { + return (data != null ? _i102.UserCredential.fromJson(data) : null) as T; } - if (t == _i1.getType<_i102.Visit?>()) { - return (data != null ? _i102.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i103.Visit?>()) { + return (data != null ? _i103.Visit.fromJson(data) : null) as T; } if (t == List<_i13.AdminAlert>) { return (data as List).map((e) => deserialize<_i13.AdminAlert>(e)).toList() @@ -984,58 +995,58 @@ class Protocol extends _i1.SerializationManager { ) as T; } - if (t == List<_i103.AdminMicroArea>) { + if (t == List<_i104.AdminMicroArea>) { return (data as List) - .map((e) => deserialize<_i103.AdminMicroArea>(e)) + .map((e) => deserialize<_i104.AdminMicroArea>(e)) .toList() as T; } - if (t == List<_i104.AdminAcs>) { - return (data as List).map((e) => deserialize<_i104.AdminAcs>(e)).toList() + if (t == List<_i105.AdminAcs>) { + return (data as List).map((e) => deserialize<_i105.AdminAcs>(e)).toList() as T; } - if (t == List<_i105.AdminStaff>) { + if (t == List<_i106.AdminStaff>) { return (data as List) - .map((e) => deserialize<_i105.AdminStaff>(e)) + .map((e) => deserialize<_i106.AdminStaff>(e)) .toList() as T; } - if (t == List<_i106.AdminAcsDevice>) { + if (t == List<_i107.AdminAcsDevice>) { return (data as List) - .map((e) => deserialize<_i106.AdminAcsDevice>(e)) + .map((e) => deserialize<_i107.AdminAcsDevice>(e)) .toList() as T; } - if (t == List<_i107.TermsVersionEntry>) { + if (t == List<_i108.TermsVersionEntry>) { return (data as List) - .map((e) => deserialize<_i107.TermsVersionEntry>(e)) + .map((e) => deserialize<_i108.TermsVersionEntry>(e)) .toList() as T; } - if (t == List<_i108.AcsOpenAlert>) { + if (t == List<_i109.AcsOpenAlert>) { return (data as List) - .map((e) => deserialize<_i108.AcsOpenAlert>(e)) + .map((e) => deserialize<_i109.AcsOpenAlert>(e)) .toList() as T; } - if (t == List<_i109.MicroAreaPatient>) { + if (t == List<_i110.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i109.MicroAreaPatient>(e)) + .map((e) => deserialize<_i110.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i110.VisitSyncResult>) { + if (t == List<_i111.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i110.VisitSyncResult>(e)) + .map((e) => deserialize<_i111.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i111.VisitSyncEntry>) { + if (t == List<_i112.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i111.VisitSyncEntry>(e)) + .map((e) => deserialize<_i112.VisitSyncEntry>(e)) .toList() as T; } @@ -1126,26 +1137,27 @@ class Protocol extends _i1.SerializationManager { _i80.AlertValidationException => 'AlertValidationException', _i81.AuthenticationFailedException => 'AuthenticationFailedException', _i82.DataRightsException => 'DataRightsException', - _i83.EndpointDisabledException => 'EndpointDisabledException', - _i84.EnrollmentException => 'EnrollmentException', - _i85.MfaEnrollmentRequiredException => 'MfaEnrollmentRequiredException', - _i86.MfaRequiredException => 'MfaRequiredException', - _i87.NoticeDeliveryException => 'NoticeDeliveryException', - _i88.OtpRequestException => 'OtpRequestException', - _i89.SessionExpiredException => 'SessionExpiredException', - _i90.MicroArea => 'MicroArea', - _i91.OtpChallenge => 'OtpChallenge', - _i92.Patient => 'Patient', - _i93.PushToken => 'PushToken', - _i94.ScheduledNotice => 'ScheduledNotice', - _i95.StaffAccount => 'StaffAccount', - _i96.TermsVersion => 'TermsVersion', - _i97.TriageAnswer => 'TriageAnswer', - _i98.TriageSession => 'TriageSession', - _i99.Ubs => 'Ubs', - _i100.User => 'User', - _i101.UserCredential => 'UserCredential', - _i102.Visit => 'Visit', + _i83.DeviceWipeRequiredException => 'DeviceWipeRequiredException', + _i84.EndpointDisabledException => 'EndpointDisabledException', + _i85.EnrollmentException => 'EnrollmentException', + _i86.MfaEnrollmentRequiredException => 'MfaEnrollmentRequiredException', + _i87.MfaRequiredException => 'MfaRequiredException', + _i88.NoticeDeliveryException => 'NoticeDeliveryException', + _i89.OtpRequestException => 'OtpRequestException', + _i90.SessionExpiredException => 'SessionExpiredException', + _i91.MicroArea => 'MicroArea', + _i92.OtpChallenge => 'OtpChallenge', + _i93.Patient => 'Patient', + _i94.PushToken => 'PushToken', + _i95.ScheduledNotice => 'ScheduledNotice', + _i96.StaffAccount => 'StaffAccount', + _i97.TermsVersion => 'TermsVersion', + _i98.TriageAnswer => 'TriageAnswer', + _i99.TriageSession => 'TriageSession', + _i100.Ubs => 'Ubs', + _i101.User => 'User', + _i102.UserCredential => 'UserCredential', + _i103.Visit => 'Visit', _ => null, }; } @@ -1322,45 +1334,47 @@ class Protocol extends _i1.SerializationManager { return 'AuthenticationFailedException'; case _i82.DataRightsException(): return 'DataRightsException'; - case _i83.EndpointDisabledException(): + case _i83.DeviceWipeRequiredException(): + return 'DeviceWipeRequiredException'; + case _i84.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i84.EnrollmentException(): + case _i85.EnrollmentException(): return 'EnrollmentException'; - case _i85.MfaEnrollmentRequiredException(): + case _i86.MfaEnrollmentRequiredException(): return 'MfaEnrollmentRequiredException'; - case _i86.MfaRequiredException(): + case _i87.MfaRequiredException(): return 'MfaRequiredException'; - case _i87.NoticeDeliveryException(): + case _i88.NoticeDeliveryException(): return 'NoticeDeliveryException'; - case _i88.OtpRequestException(): + case _i89.OtpRequestException(): return 'OtpRequestException'; - case _i89.SessionExpiredException(): + case _i90.SessionExpiredException(): return 'SessionExpiredException'; - case _i90.MicroArea(): + case _i91.MicroArea(): return 'MicroArea'; - case _i91.OtpChallenge(): + case _i92.OtpChallenge(): return 'OtpChallenge'; - case _i92.Patient(): + case _i93.Patient(): return 'Patient'; - case _i93.PushToken(): + case _i94.PushToken(): return 'PushToken'; - case _i94.ScheduledNotice(): + case _i95.ScheduledNotice(): return 'ScheduledNotice'; - case _i95.StaffAccount(): + case _i96.StaffAccount(): return 'StaffAccount'; - case _i96.TermsVersion(): + case _i97.TermsVersion(): return 'TermsVersion'; - case _i97.TriageAnswer(): + case _i98.TriageAnswer(): return 'TriageAnswer'; - case _i98.TriageSession(): + case _i99.TriageSession(): return 'TriageSession'; - case _i99.Ubs(): + case _i100.Ubs(): return 'Ubs'; - case _i100.User(): + case _i101.User(): return 'User'; - case _i101.UserCredential(): + case _i102.UserCredential(): return 'UserCredential'; - case _i102.Visit(): + case _i103.Visit(): return 'Visit'; } return null; @@ -1615,65 +1629,68 @@ class Protocol extends _i1.SerializationManager { if (dataClassName == 'DataRightsException') { return deserialize<_i82.DataRightsException>(data['data']); } + if (dataClassName == 'DeviceWipeRequiredException') { + return deserialize<_i83.DeviceWipeRequiredException>(data['data']); + } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i83.EndpointDisabledException>(data['data']); + return deserialize<_i84.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i84.EnrollmentException>(data['data']); + return deserialize<_i85.EnrollmentException>(data['data']); } if (dataClassName == 'MfaEnrollmentRequiredException') { - return deserialize<_i85.MfaEnrollmentRequiredException>(data['data']); + return deserialize<_i86.MfaEnrollmentRequiredException>(data['data']); } if (dataClassName == 'MfaRequiredException') { - return deserialize<_i86.MfaRequiredException>(data['data']); + return deserialize<_i87.MfaRequiredException>(data['data']); } if (dataClassName == 'NoticeDeliveryException') { - return deserialize<_i87.NoticeDeliveryException>(data['data']); + return deserialize<_i88.NoticeDeliveryException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i88.OtpRequestException>(data['data']); + return deserialize<_i89.OtpRequestException>(data['data']); } if (dataClassName == 'SessionExpiredException') { - return deserialize<_i89.SessionExpiredException>(data['data']); + return deserialize<_i90.SessionExpiredException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i90.MicroArea>(data['data']); + return deserialize<_i91.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i91.OtpChallenge>(data['data']); + return deserialize<_i92.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i92.Patient>(data['data']); + return deserialize<_i93.Patient>(data['data']); } if (dataClassName == 'PushToken') { - return deserialize<_i93.PushToken>(data['data']); + return deserialize<_i94.PushToken>(data['data']); } if (dataClassName == 'ScheduledNotice') { - return deserialize<_i94.ScheduledNotice>(data['data']); + return deserialize<_i95.ScheduledNotice>(data['data']); } if (dataClassName == 'StaffAccount') { - return deserialize<_i95.StaffAccount>(data['data']); + return deserialize<_i96.StaffAccount>(data['data']); } if (dataClassName == 'TermsVersion') { - return deserialize<_i96.TermsVersion>(data['data']); + return deserialize<_i97.TermsVersion>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i97.TriageAnswer>(data['data']); + return deserialize<_i98.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i98.TriageSession>(data['data']); + return deserialize<_i99.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i99.Ubs>(data['data']); + return deserialize<_i100.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i100.User>(data['data']); + return deserialize<_i101.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i101.UserCredential>(data['data']); + return deserialize<_i102.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i102.Visit>(data['data']); + return deserialize<_i103.Visit>(data['data']); } return super.deserializeByClassName(data); } diff --git a/backend/sinalacs_server/lib/src/application/devices/device_wipe_service.dart b/backend/sinalacs_server/lib/src/application/devices/device_wipe_service.dart index ee8071a..4e32c0f 100644 --- a/backend/sinalacs_server/lib/src/application/devices/device_wipe_service.dart +++ b/backend/sinalacs_server/lib/src/application/devices/device_wipe_service.dart @@ -148,6 +148,65 @@ class DeviceWipeService { ); } + // --- Lado do aparelho (sem token): entrega, confirmação e recusa --------- + + /// O `orderId` do pedido ainda não confirmado do aparelho [deviceId], ou + /// `null`. Carimba a entrega e audita `wipe_delivered` só na primeira vez. + Future pendingOrderId(String deviceId) async { + final pedido = await _pedidoDe(deviceId); + if (pedido == null || pedido.confirmedAt != null) return null; + final orderId = pedido.id.toString(); + if (await store.markDelivered(orderId, _clock().toUtc())) { + await _auditarAparelho(pedido, 'wipe_delivered'); + } + return orderId; + } + + /// Confirma a limpeza. Só a primeira confirmação do par certo grava e audita; + /// par errado ou repetição são um no-op silencioso (não vira oráculo). + Future confirm(String deviceId, String orderId) async { + if (deviceId.trim().isEmpty) return; + final pedido = await store.orderFor(deviceId); + if (pedido == null || pedido.id.toString() != orderId) return; + if (await store.markConfirmed(deviceId, orderId, _clock().toUtc())) { + await _auditarAparelho(pedido, 'wipe_confirmed'); + } + } + + /// Lança [DeviceWipeRequiredException] quando existe **qualquer** pedido para + /// [deviceId], confirmado ou não: o aparelho limpo troca de id (D1), então um + /// id antigo que reaparece é aparelho a limpar. `null`, vazio e a sentinela + /// não são checados. + Future ensureDeviceNotMarked(String? deviceId) async { + final pedido = await _pedidoDe(deviceId); + if (pedido == null) return; + throw DeviceWipeRequiredException( + orderId: pedido.id.toString(), + message: 'Este aparelho precisa ser limpo a pedido da coordenação.', + ); + } + + Future _pedidoDe(String? deviceId) async { + if (deviceId == null || + deviceId.trim().isEmpty || + deviceId == InstitutionalAuthService.deviceIdAbsent) { + return null; + } + return store.orderFor(deviceId); + } + + /// O aparelho age em nome da conta do pedido (`targetUserId`). + Future _auditarAparelho(DeviceWipeOrder pedido, String result) => + audit.record( + AuditEvent( + userId: pedido.targetUserId.toString(), + actionType: 'write', + resourceType: 'acs_device', + resourceId: pedido.id.toString(), + result: result, + ), + ); + /// Recusa com linha `denied` ANTES de lançar; a mesma mensagem para "não /// existe" e "não é seu". Future _negar( diff --git a/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart index 27ac2ee..6f7f78b 100644 --- a/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart @@ -84,6 +84,12 @@ class AuthEndpoint extends Endpoint { }) async { final runtime = AlertRuntime.instance; + // Aparelho marcado para limpeza (#81): a resposta é a limpeza, antes de + // olhar a senha, para o app não confundi-la com credencial recusada. + await runtime + .deviceWipeServiceFor(session) + .ensureDeviceNotMarked(deviceId); + final user = await runtime.institutionalAuthServiceFor(session).login( matricula: matricula, password: password, @@ -187,6 +193,11 @@ class AuthEndpoint extends Endpoint { required String deviceId, }) async { final runtime = AlertRuntime.instance; + // O pedido de limpeza já revogou os tokens do aparelho (#81); checar antes + // faz a resposta ser a limpeza, não a `SessionExpiredException` genérica. + await runtime + .deviceWipeServiceFor(session) + .ensureDeviceNotMarked(deviceId); final renewed = await runtime .refreshTokenServiceFor(session) .refresh(refreshToken: refreshToken, deviceId: deviceId); diff --git a/backend/sinalacs_server/lib/src/endpoints/devices_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/devices_endpoint.dart index b16f811..932cfe1 100644 --- a/backend/sinalacs_server/lib/src/endpoints/devices_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/devices_endpoint.dart @@ -23,4 +23,22 @@ class DevicesEndpoint extends AuthenticatedEndpoint { throw AlertPermissionException(message: error.message); } } + + /// O `orderId` do pedido de limpeza remota do aparelho, ou `null` (#81). + /// Público: o aparelho cujo token foi revogado precisa perguntar. Não devolve + /// identidade; só o id do pedido, que é um UUID aleatório. + Future pendingWipe(Session session, String deviceId) => AlertRuntime + .instance + .deviceWipeServiceFor(session) + .pendingOrderId(deviceId); + + /// O aparelho confirma que apagou tudo (#81). Sem token e sem retorno: um par + /// errado ou repetido se comporta igual (no-op), então não vira oráculo. + Future confirmWipe( + Session session, + String deviceId, + String orderId, + ) => AlertRuntime.instance + .deviceWipeServiceFor(session) + .confirm(deviceId, orderId); } diff --git a/backend/sinalacs_server/lib/src/endpoints/visits_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/visits_endpoint.dart index e707531..692947a 100644 --- a/backend/sinalacs_server/lib/src/endpoints/visits_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/visits_endpoint.dart @@ -122,6 +122,11 @@ class VisitsEndpoint extends AuthenticatedEndpoint { required List visits, }) async { final runtime = AlertRuntime.instance; + // Aparelho marcado para limpeza (#81): um login ou renovação em corrida com + // o pedido pode ter emitido token vivo depois da revogação. + await runtime + .deviceWipeServiceFor(session) + .ensureDeviceNotMarked(deviceId); try { // Teto do lote ANTES de resolver o token e de abrir a transação. VisitSyncService.checkLegacyBatchSize(visits.length); diff --git a/backend/sinalacs_server/lib/src/generated/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index 38e03ad..a5e6334 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -1450,6 +1450,50 @@ class Endpoints extends _i1.EndpointDispatch { platform: params['platform'], ), ), + 'pendingWipe': _i1.MethodConnector( + name: 'pendingWipe', + params: { + 'deviceId': _i1.ParameterDescription( + name: 'deviceId', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['devices'] as _i5.DevicesEndpoint).pendingWipe( + session, + params['deviceId'], + ), + ), + 'confirmWipe': _i1.MethodConnector( + name: 'confirmWipe', + params: { + 'deviceId': _i1.ParameterDescription( + name: 'deviceId', + type: _i1.getType(), + nullable: false, + ), + 'orderId': _i1.ParameterDescription( + name: 'orderId', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['devices'] as _i5.DevicesEndpoint).confirmWipe( + session, + params['deviceId'], + params['orderId'], + ), + ), }, ); connectors['health'] = _i1.EndpointConnector( diff --git a/backend/sinalacs_server/lib/src/generated/exceptions/device_wipe_required_exception.dart b/backend/sinalacs_server/lib/src/generated/exceptions/device_wipe_required_exception.dart new file mode 100644 index 0000000..6efbb15 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/exceptions/device_wipe_required_exception.dart @@ -0,0 +1,100 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod/serverpod.dart' as _i1; + +/// O aparelho tem um pedido de limpeza remota da coordenação (#81). Login, +/// renovação e envio diferido o recusam com isto, para o app distinguir "limpar +/// o aparelho" de "sessão expirada". +abstract class DeviceWipeRequiredException + implements + _i1.SerializableException, + _i1.SerializableModel, + _i1.ProtocolSerialization { + DeviceWipeRequiredException._({ + required this.orderId, + required this.message, + }); + + factory DeviceWipeRequiredException({ + required String orderId, + required String message, + }) = _DeviceWipeRequiredExceptionImpl; + + factory DeviceWipeRequiredException.fromJson( + Map jsonSerialization, + ) { + return DeviceWipeRequiredException( + orderId: jsonSerialization['orderId'] as String, + message: jsonSerialization['message'] as String, + ); + } + + String orderId; + + String message; + + /// Returns a shallow copy of this [DeviceWipeRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + DeviceWipeRequiredException copyWith({ + String? orderId, + String? message, + }); + @override + Map toJson() { + return { + '__className__': 'DeviceWipeRequiredException', + 'orderId': orderId, + 'message': message, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'DeviceWipeRequiredException', + 'orderId': orderId, + 'message': message, + }; + } + + @override + String toString() { + return 'DeviceWipeRequiredException(orderId: $orderId, message: $message)'; + } +} + +class _DeviceWipeRequiredExceptionImpl extends DeviceWipeRequiredException { + _DeviceWipeRequiredExceptionImpl({ + required String orderId, + required String message, + }) : super._( + orderId: orderId, + message: message, + ); + + /// Returns a shallow copy of this [DeviceWipeRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + DeviceWipeRequiredException copyWith({ + String? orderId, + String? message, + }) { + return DeviceWipeRequiredException( + orderId: orderId ?? this.orderId, + message: message ?? this.message, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/protocol.dart b/backend/sinalacs_server/lib/src/generated/protocol.dart index c8e5c2d..74cb151 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.dart +++ b/backend/sinalacs_server/lib/src/generated/protocol.dart @@ -94,41 +94,42 @@ import 'exceptions/alert_permission_exception.dart' as _i80; import 'exceptions/alert_validation_exception.dart' as _i81; import 'exceptions/authentication_failed_exception.dart' as _i82; import 'exceptions/data_rights_exception.dart' as _i83; -import 'exceptions/endpoint_disabled_exception.dart' as _i84; -import 'exceptions/enrollment_exception.dart' as _i85; -import 'exceptions/mfa_enrollment_required_exception.dart' as _i86; -import 'exceptions/mfa_required_exception.dart' as _i87; -import 'exceptions/notice_delivery_exception.dart' as _i88; -import 'exceptions/otp_request_exception.dart' as _i89; -import 'exceptions/session_expired_exception.dart' as _i90; -import 'micro_area.dart' as _i91; -import 'otp_challenge.dart' as _i92; -import 'patient.dart' as _i93; -import 'push_token.dart' as _i94; -import 'scheduled_notice.dart' as _i95; -import 'staff_account.dart' as _i96; -import 'terms_version.dart' as _i97; -import 'triage_answer.dart' as _i98; -import 'triage_session.dart' as _i99; -import 'ubs.dart' as _i100; -import 'user.dart' as _i101; -import 'user_credential.dart' as _i102; -import 'visit.dart' as _i103; +import 'exceptions/device_wipe_required_exception.dart' as _i84; +import 'exceptions/endpoint_disabled_exception.dart' as _i85; +import 'exceptions/enrollment_exception.dart' as _i86; +import 'exceptions/mfa_enrollment_required_exception.dart' as _i87; +import 'exceptions/mfa_required_exception.dart' as _i88; +import 'exceptions/notice_delivery_exception.dart' as _i89; +import 'exceptions/otp_request_exception.dart' as _i90; +import 'exceptions/session_expired_exception.dart' as _i91; +import 'micro_area.dart' as _i92; +import 'otp_challenge.dart' as _i93; +import 'patient.dart' as _i94; +import 'push_token.dart' as _i95; +import 'scheduled_notice.dart' as _i96; +import 'staff_account.dart' as _i97; +import 'terms_version.dart' as _i98; +import 'triage_answer.dart' as _i99; +import 'triage_session.dart' as _i100; +import 'ubs.dart' as _i101; +import 'user.dart' as _i102; +import 'user_credential.dart' as _i103; +import 'visit.dart' as _i104; import 'package:sinalacs_server/src/generated/api/admin_micro_area.dart' - as _i104; -import 'package:sinalacs_server/src/generated/api/admin_acs.dart' as _i105; -import 'package:sinalacs_server/src/generated/api/admin_staff.dart' as _i106; + as _i105; +import 'package:sinalacs_server/src/generated/api/admin_acs.dart' as _i106; +import 'package:sinalacs_server/src/generated/api/admin_staff.dart' as _i107; import 'package:sinalacs_server/src/generated/api/admin_acs_device.dart' - as _i107; -import 'package:sinalacs_server/src/generated/api/terms_version_entry.dart' as _i108; -import 'package:sinalacs_server/src/generated/api/acs_open_alert.dart' as _i109; +import 'package:sinalacs_server/src/generated/api/terms_version_entry.dart' + as _i109; +import 'package:sinalacs_server/src/generated/api/acs_open_alert.dart' as _i110; import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' - as _i110; -import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' as _i111; -import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' +import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' as _i112; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i113; export 'acs.dart'; export 'acs_refresh_token.dart'; export 'acs_upload_token.dart'; @@ -210,6 +211,7 @@ export 'exceptions/alert_permission_exception.dart'; export 'exceptions/alert_validation_exception.dart'; export 'exceptions/authentication_failed_exception.dart'; export 'exceptions/data_rights_exception.dart'; +export 'exceptions/device_wipe_required_exception.dart'; export 'exceptions/endpoint_disabled_exception.dart'; export 'exceptions/enrollment_exception.dart'; export 'exceptions/mfa_enrollment_required_exception.dart'; @@ -3375,65 +3377,68 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i83.DataRightsException) { return _i83.DataRightsException.fromJson(data) as T; } - if (t == _i84.EndpointDisabledException) { - return _i84.EndpointDisabledException.fromJson(data) as T; + if (t == _i84.DeviceWipeRequiredException) { + return _i84.DeviceWipeRequiredException.fromJson(data) as T; } - if (t == _i85.EnrollmentException) { - return _i85.EnrollmentException.fromJson(data) as T; + if (t == _i85.EndpointDisabledException) { + return _i85.EndpointDisabledException.fromJson(data) as T; } - if (t == _i86.MfaEnrollmentRequiredException) { - return _i86.MfaEnrollmentRequiredException.fromJson(data) as T; + if (t == _i86.EnrollmentException) { + return _i86.EnrollmentException.fromJson(data) as T; } - if (t == _i87.MfaRequiredException) { - return _i87.MfaRequiredException.fromJson(data) as T; + if (t == _i87.MfaEnrollmentRequiredException) { + return _i87.MfaEnrollmentRequiredException.fromJson(data) as T; } - if (t == _i88.NoticeDeliveryException) { - return _i88.NoticeDeliveryException.fromJson(data) as T; + if (t == _i88.MfaRequiredException) { + return _i88.MfaRequiredException.fromJson(data) as T; } - if (t == _i89.OtpRequestException) { - return _i89.OtpRequestException.fromJson(data) as T; + if (t == _i89.NoticeDeliveryException) { + return _i89.NoticeDeliveryException.fromJson(data) as T; } - if (t == _i90.SessionExpiredException) { - return _i90.SessionExpiredException.fromJson(data) as T; + if (t == _i90.OtpRequestException) { + return _i90.OtpRequestException.fromJson(data) as T; } - if (t == _i91.MicroArea) { - return _i91.MicroArea.fromJson(data) as T; + if (t == _i91.SessionExpiredException) { + return _i91.SessionExpiredException.fromJson(data) as T; } - if (t == _i92.OtpChallenge) { - return _i92.OtpChallenge.fromJson(data) as T; + if (t == _i92.MicroArea) { + return _i92.MicroArea.fromJson(data) as T; } - if (t == _i93.Patient) { - return _i93.Patient.fromJson(data) as T; + if (t == _i93.OtpChallenge) { + return _i93.OtpChallenge.fromJson(data) as T; } - if (t == _i94.PushToken) { - return _i94.PushToken.fromJson(data) as T; + if (t == _i94.Patient) { + return _i94.Patient.fromJson(data) as T; } - if (t == _i95.ScheduledNotice) { - return _i95.ScheduledNotice.fromJson(data) as T; + if (t == _i95.PushToken) { + return _i95.PushToken.fromJson(data) as T; } - if (t == _i96.StaffAccount) { - return _i96.StaffAccount.fromJson(data) as T; + if (t == _i96.ScheduledNotice) { + return _i96.ScheduledNotice.fromJson(data) as T; } - if (t == _i97.TermsVersion) { - return _i97.TermsVersion.fromJson(data) as T; + if (t == _i97.StaffAccount) { + return _i97.StaffAccount.fromJson(data) as T; } - if (t == _i98.TriageAnswer) { - return _i98.TriageAnswer.fromJson(data) as T; + if (t == _i98.TermsVersion) { + return _i98.TermsVersion.fromJson(data) as T; } - if (t == _i99.TriageSession) { - return _i99.TriageSession.fromJson(data) as T; + if (t == _i99.TriageAnswer) { + return _i99.TriageAnswer.fromJson(data) as T; } - if (t == _i100.Ubs) { - return _i100.Ubs.fromJson(data) as T; + if (t == _i100.TriageSession) { + return _i100.TriageSession.fromJson(data) as T; } - if (t == _i101.User) { - return _i101.User.fromJson(data) as T; + if (t == _i101.Ubs) { + return _i101.Ubs.fromJson(data) as T; } - if (t == _i102.UserCredential) { - return _i102.UserCredential.fromJson(data) as T; + if (t == _i102.User) { + return _i102.User.fromJson(data) as T; } - if (t == _i103.Visit) { - return _i103.Visit.fromJson(data) as T; + if (t == _i103.UserCredential) { + return _i103.UserCredential.fromJson(data) as T; + } + if (t == _i104.Visit) { + return _i104.Visit.fromJson(data) as T; } if (t == _i1.getType<_i3.Acs?>()) { return (data != null ? _i3.Acs.fromJson(data) : null) as T; @@ -3731,76 +3736,82 @@ class Protocol extends _i1.SerializationManagerServer { return (data != null ? _i83.DataRightsException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i84.EndpointDisabledException?>()) { + if (t == _i1.getType<_i84.DeviceWipeRequiredException?>()) { + return (data != null + ? _i84.DeviceWipeRequiredException.fromJson(data) + : null) + as T; + } + if (t == _i1.getType<_i85.EndpointDisabledException?>()) { return (data != null - ? _i84.EndpointDisabledException.fromJson(data) + ? _i85.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i85.EnrollmentException?>()) { - return (data != null ? _i85.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i86.EnrollmentException?>()) { + return (data != null ? _i86.EnrollmentException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i86.MfaEnrollmentRequiredException?>()) { + if (t == _i1.getType<_i87.MfaEnrollmentRequiredException?>()) { return (data != null - ? _i86.MfaEnrollmentRequiredException.fromJson(data) + ? _i87.MfaEnrollmentRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i87.MfaRequiredException?>()) { - return (data != null ? _i87.MfaRequiredException.fromJson(data) : null) + if (t == _i1.getType<_i88.MfaRequiredException?>()) { + return (data != null ? _i88.MfaRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i88.NoticeDeliveryException?>()) { - return (data != null ? _i88.NoticeDeliveryException.fromJson(data) : null) + if (t == _i1.getType<_i89.NoticeDeliveryException?>()) { + return (data != null ? _i89.NoticeDeliveryException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i89.OtpRequestException?>()) { - return (data != null ? _i89.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i90.OtpRequestException?>()) { + return (data != null ? _i90.OtpRequestException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i90.SessionExpiredException?>()) { - return (data != null ? _i90.SessionExpiredException.fromJson(data) : null) + if (t == _i1.getType<_i91.SessionExpiredException?>()) { + return (data != null ? _i91.SessionExpiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i91.MicroArea?>()) { - return (data != null ? _i91.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i92.MicroArea?>()) { + return (data != null ? _i92.MicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i92.OtpChallenge?>()) { - return (data != null ? _i92.OtpChallenge.fromJson(data) : null) as T; + if (t == _i1.getType<_i93.OtpChallenge?>()) { + return (data != null ? _i93.OtpChallenge.fromJson(data) : null) as T; } - if (t == _i1.getType<_i93.Patient?>()) { - return (data != null ? _i93.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i94.Patient?>()) { + return (data != null ? _i94.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i94.PushToken?>()) { - return (data != null ? _i94.PushToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i95.PushToken?>()) { + return (data != null ? _i95.PushToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i95.ScheduledNotice?>()) { - return (data != null ? _i95.ScheduledNotice.fromJson(data) : null) as T; + if (t == _i1.getType<_i96.ScheduledNotice?>()) { + return (data != null ? _i96.ScheduledNotice.fromJson(data) : null) as T; } - if (t == _i1.getType<_i96.StaffAccount?>()) { - return (data != null ? _i96.StaffAccount.fromJson(data) : null) as T; + if (t == _i1.getType<_i97.StaffAccount?>()) { + return (data != null ? _i97.StaffAccount.fromJson(data) : null) as T; } - if (t == _i1.getType<_i97.TermsVersion?>()) { - return (data != null ? _i97.TermsVersion.fromJson(data) : null) as T; + if (t == _i1.getType<_i98.TermsVersion?>()) { + return (data != null ? _i98.TermsVersion.fromJson(data) : null) as T; } - if (t == _i1.getType<_i98.TriageAnswer?>()) { - return (data != null ? _i98.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i99.TriageAnswer?>()) { + return (data != null ? _i99.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i99.TriageSession?>()) { - return (data != null ? _i99.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i100.TriageSession?>()) { + return (data != null ? _i100.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i100.Ubs?>()) { - return (data != null ? _i100.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i101.Ubs?>()) { + return (data != null ? _i101.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i101.User?>()) { - return (data != null ? _i101.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i102.User?>()) { + return (data != null ? _i102.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i102.UserCredential?>()) { - return (data != null ? _i102.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i103.UserCredential?>()) { + return (data != null ? _i103.UserCredential.fromJson(data) : null) as T; } - if (t == _i1.getType<_i103.Visit?>()) { - return (data != null ? _i103.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i104.Visit?>()) { + return (data != null ? _i104.Visit.fromJson(data) : null) as T; } if (t == List<_i14.AdminAlert>) { return (data as List).map((e) => deserialize<_i14.AdminAlert>(e)).toList() @@ -3851,58 +3862,58 @@ class Protocol extends _i1.SerializationManagerServer { ) as T; } - if (t == List<_i104.AdminMicroArea>) { + if (t == List<_i105.AdminMicroArea>) { return (data as List) - .map((e) => deserialize<_i104.AdminMicroArea>(e)) + .map((e) => deserialize<_i105.AdminMicroArea>(e)) .toList() as T; } - if (t == List<_i105.AdminAcs>) { - return (data as List).map((e) => deserialize<_i105.AdminAcs>(e)).toList() + if (t == List<_i106.AdminAcs>) { + return (data as List).map((e) => deserialize<_i106.AdminAcs>(e)).toList() as T; } - if (t == List<_i106.AdminStaff>) { + if (t == List<_i107.AdminStaff>) { return (data as List) - .map((e) => deserialize<_i106.AdminStaff>(e)) + .map((e) => deserialize<_i107.AdminStaff>(e)) .toList() as T; } - if (t == List<_i107.AdminAcsDevice>) { + if (t == List<_i108.AdminAcsDevice>) { return (data as List) - .map((e) => deserialize<_i107.AdminAcsDevice>(e)) + .map((e) => deserialize<_i108.AdminAcsDevice>(e)) .toList() as T; } - if (t == List<_i108.TermsVersionEntry>) { + if (t == List<_i109.TermsVersionEntry>) { return (data as List) - .map((e) => deserialize<_i108.TermsVersionEntry>(e)) + .map((e) => deserialize<_i109.TermsVersionEntry>(e)) .toList() as T; } - if (t == List<_i109.AcsOpenAlert>) { + if (t == List<_i110.AcsOpenAlert>) { return (data as List) - .map((e) => deserialize<_i109.AcsOpenAlert>(e)) + .map((e) => deserialize<_i110.AcsOpenAlert>(e)) .toList() as T; } - if (t == List<_i110.MicroAreaPatient>) { + if (t == List<_i111.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i110.MicroAreaPatient>(e)) + .map((e) => deserialize<_i111.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i111.VisitSyncResult>) { + if (t == List<_i112.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i111.VisitSyncResult>(e)) + .map((e) => deserialize<_i112.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i112.VisitSyncEntry>) { + if (t == List<_i113.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i112.VisitSyncEntry>(e)) + .map((e) => deserialize<_i113.VisitSyncEntry>(e)) .toList() as T; } @@ -3996,26 +4007,27 @@ class Protocol extends _i1.SerializationManagerServer { _i81.AlertValidationException => 'AlertValidationException', _i82.AuthenticationFailedException => 'AuthenticationFailedException', _i83.DataRightsException => 'DataRightsException', - _i84.EndpointDisabledException => 'EndpointDisabledException', - _i85.EnrollmentException => 'EnrollmentException', - _i86.MfaEnrollmentRequiredException => 'MfaEnrollmentRequiredException', - _i87.MfaRequiredException => 'MfaRequiredException', - _i88.NoticeDeliveryException => 'NoticeDeliveryException', - _i89.OtpRequestException => 'OtpRequestException', - _i90.SessionExpiredException => 'SessionExpiredException', - _i91.MicroArea => 'MicroArea', - _i92.OtpChallenge => 'OtpChallenge', - _i93.Patient => 'Patient', - _i94.PushToken => 'PushToken', - _i95.ScheduledNotice => 'ScheduledNotice', - _i96.StaffAccount => 'StaffAccount', - _i97.TermsVersion => 'TermsVersion', - _i98.TriageAnswer => 'TriageAnswer', - _i99.TriageSession => 'TriageSession', - _i100.Ubs => 'Ubs', - _i101.User => 'User', - _i102.UserCredential => 'UserCredential', - _i103.Visit => 'Visit', + _i84.DeviceWipeRequiredException => 'DeviceWipeRequiredException', + _i85.EndpointDisabledException => 'EndpointDisabledException', + _i86.EnrollmentException => 'EnrollmentException', + _i87.MfaEnrollmentRequiredException => 'MfaEnrollmentRequiredException', + _i88.MfaRequiredException => 'MfaRequiredException', + _i89.NoticeDeliveryException => 'NoticeDeliveryException', + _i90.OtpRequestException => 'OtpRequestException', + _i91.SessionExpiredException => 'SessionExpiredException', + _i92.MicroArea => 'MicroArea', + _i93.OtpChallenge => 'OtpChallenge', + _i94.Patient => 'Patient', + _i95.PushToken => 'PushToken', + _i96.ScheduledNotice => 'ScheduledNotice', + _i97.StaffAccount => 'StaffAccount', + _i98.TermsVersion => 'TermsVersion', + _i99.TriageAnswer => 'TriageAnswer', + _i100.TriageSession => 'TriageSession', + _i101.Ubs => 'Ubs', + _i102.User => 'User', + _i103.UserCredential => 'UserCredential', + _i104.Visit => 'Visit', _ => null, }; } @@ -4192,45 +4204,47 @@ class Protocol extends _i1.SerializationManagerServer { return 'AuthenticationFailedException'; case _i83.DataRightsException(): return 'DataRightsException'; - case _i84.EndpointDisabledException(): + case _i84.DeviceWipeRequiredException(): + return 'DeviceWipeRequiredException'; + case _i85.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i85.EnrollmentException(): + case _i86.EnrollmentException(): return 'EnrollmentException'; - case _i86.MfaEnrollmentRequiredException(): + case _i87.MfaEnrollmentRequiredException(): return 'MfaEnrollmentRequiredException'; - case _i87.MfaRequiredException(): + case _i88.MfaRequiredException(): return 'MfaRequiredException'; - case _i88.NoticeDeliveryException(): + case _i89.NoticeDeliveryException(): return 'NoticeDeliveryException'; - case _i89.OtpRequestException(): + case _i90.OtpRequestException(): return 'OtpRequestException'; - case _i90.SessionExpiredException(): + case _i91.SessionExpiredException(): return 'SessionExpiredException'; - case _i91.MicroArea(): + case _i92.MicroArea(): return 'MicroArea'; - case _i92.OtpChallenge(): + case _i93.OtpChallenge(): return 'OtpChallenge'; - case _i93.Patient(): + case _i94.Patient(): return 'Patient'; - case _i94.PushToken(): + case _i95.PushToken(): return 'PushToken'; - case _i95.ScheduledNotice(): + case _i96.ScheduledNotice(): return 'ScheduledNotice'; - case _i96.StaffAccount(): + case _i97.StaffAccount(): return 'StaffAccount'; - case _i97.TermsVersion(): + case _i98.TermsVersion(): return 'TermsVersion'; - case _i98.TriageAnswer(): + case _i99.TriageAnswer(): return 'TriageAnswer'; - case _i99.TriageSession(): + case _i100.TriageSession(): return 'TriageSession'; - case _i100.Ubs(): + case _i101.Ubs(): return 'Ubs'; - case _i101.User(): + case _i102.User(): return 'User'; - case _i102.UserCredential(): + case _i103.UserCredential(): return 'UserCredential'; - case _i103.Visit(): + case _i104.Visit(): return 'Visit'; } className = _i2.Protocol().getClassNameForObject(data); @@ -4489,65 +4503,68 @@ class Protocol extends _i1.SerializationManagerServer { if (dataClassName == 'DataRightsException') { return deserialize<_i83.DataRightsException>(data['data']); } + if (dataClassName == 'DeviceWipeRequiredException') { + return deserialize<_i84.DeviceWipeRequiredException>(data['data']); + } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i84.EndpointDisabledException>(data['data']); + return deserialize<_i85.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i85.EnrollmentException>(data['data']); + return deserialize<_i86.EnrollmentException>(data['data']); } if (dataClassName == 'MfaEnrollmentRequiredException') { - return deserialize<_i86.MfaEnrollmentRequiredException>(data['data']); + return deserialize<_i87.MfaEnrollmentRequiredException>(data['data']); } if (dataClassName == 'MfaRequiredException') { - return deserialize<_i87.MfaRequiredException>(data['data']); + return deserialize<_i88.MfaRequiredException>(data['data']); } if (dataClassName == 'NoticeDeliveryException') { - return deserialize<_i88.NoticeDeliveryException>(data['data']); + return deserialize<_i89.NoticeDeliveryException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i89.OtpRequestException>(data['data']); + return deserialize<_i90.OtpRequestException>(data['data']); } if (dataClassName == 'SessionExpiredException') { - return deserialize<_i90.SessionExpiredException>(data['data']); + return deserialize<_i91.SessionExpiredException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i91.MicroArea>(data['data']); + return deserialize<_i92.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i92.OtpChallenge>(data['data']); + return deserialize<_i93.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i93.Patient>(data['data']); + return deserialize<_i94.Patient>(data['data']); } if (dataClassName == 'PushToken') { - return deserialize<_i94.PushToken>(data['data']); + return deserialize<_i95.PushToken>(data['data']); } if (dataClassName == 'ScheduledNotice') { - return deserialize<_i95.ScheduledNotice>(data['data']); + return deserialize<_i96.ScheduledNotice>(data['data']); } if (dataClassName == 'StaffAccount') { - return deserialize<_i96.StaffAccount>(data['data']); + return deserialize<_i97.StaffAccount>(data['data']); } if (dataClassName == 'TermsVersion') { - return deserialize<_i97.TermsVersion>(data['data']); + return deserialize<_i98.TermsVersion>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i98.TriageAnswer>(data['data']); + return deserialize<_i99.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i99.TriageSession>(data['data']); + return deserialize<_i100.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i100.Ubs>(data['data']); + return deserialize<_i101.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i101.User>(data['data']); + return deserialize<_i102.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i102.UserCredential>(data['data']); + return deserialize<_i103.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i103.Visit>(data['data']); + return deserialize<_i104.Visit>(data['data']); } if (dataClassName.startsWith('serverpod.')) { data['className'] = dataClassName.substring(10); @@ -4593,30 +4610,30 @@ class Protocol extends _i1.SerializationManagerServer { return _i60.DeviceWipeOrder.t; case _i61.EnrollmentToken: return _i61.EnrollmentToken.t; - case _i91.MicroArea: - return _i91.MicroArea.t; - case _i92.OtpChallenge: - return _i92.OtpChallenge.t; - case _i93.Patient: - return _i93.Patient.t; - case _i94.PushToken: - return _i94.PushToken.t; - case _i95.ScheduledNotice: - return _i95.ScheduledNotice.t; - case _i96.StaffAccount: - return _i96.StaffAccount.t; - case _i97.TermsVersion: - return _i97.TermsVersion.t; - case _i99.TriageSession: - return _i99.TriageSession.t; - case _i100.Ubs: - return _i100.Ubs.t; - case _i101.User: - return _i101.User.t; - case _i102.UserCredential: - return _i102.UserCredential.t; - case _i103.Visit: - return _i103.Visit.t; + case _i92.MicroArea: + return _i92.MicroArea.t; + case _i93.OtpChallenge: + return _i93.OtpChallenge.t; + case _i94.Patient: + return _i94.Patient.t; + case _i95.PushToken: + return _i95.PushToken.t; + case _i96.ScheduledNotice: + return _i96.ScheduledNotice.t; + case _i97.StaffAccount: + return _i97.StaffAccount.t; + case _i98.TermsVersion: + return _i98.TermsVersion.t; + case _i100.TriageSession: + return _i100.TriageSession.t; + case _i101.Ubs: + return _i101.Ubs.t; + case _i102.User: + return _i102.User.t; + case _i103.UserCredential: + return _i103.UserCredential.t; + case _i104.Visit: + return _i104.Visit.t; } return null; } diff --git a/backend/sinalacs_server/lib/src/generated/protocol.yaml b/backend/sinalacs_server/lib/src/generated/protocol.yaml index 4e1b290..ae548b4 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.yaml +++ b/backend/sinalacs_server/lib/src/generated/protocol.yaml @@ -50,6 +50,8 @@ auth: - verifyOtp: devices: - registerPushToken: + - pendingWipe: + - confirmWipe: health: - check: notices: diff --git a/backend/sinalacs_server/lib/src/models/exceptions/device_wipe_required_exception.spy.yaml b/backend/sinalacs_server/lib/src/models/exceptions/device_wipe_required_exception.spy.yaml new file mode 100644 index 0000000..c3e89f0 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/exceptions/device_wipe_required_exception.spy.yaml @@ -0,0 +1,7 @@ +### O aparelho tem um pedido de limpeza remota da coordenação (#81). Login, +### renovação e envio diferido o recusam com isto, para o app distinguir "limpar +### o aparelho" de "sessão expirada". +exception: DeviceWipeRequiredException +fields: + orderId: String + message: String diff --git a/backend/sinalacs_server/test/integration/device_wipe_endpoint_test.dart b/backend/sinalacs_server/test/integration/device_wipe_endpoint_test.dart new file mode 100644 index 0000000..c4364b9 --- /dev/null +++ b/backend/sinalacs_server/test/integration/device_wipe_endpoint_test.dart @@ -0,0 +1,237 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import 'test_tools/runtime_harness.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Lado do aparelho da limpeza remota (#81) pelos endpoints, contra Postgres +/// real: renovação, login e envio diferido recusam o aparelho marcado, e +/// `devices.pendingWipe`/`confirmWipe` fecham o ciclo auditado. Dados +/// sintéticos; ids próprios (`...e0xx`). +const _ubsId = '00000000-0000-4000-8000-00000000e001'; +const _microAreaId = '00000000-0000-4000-8000-00000000e002'; +const _acsId = '00000000-0000-4000-8000-00000000e003'; +const _adminId = '00000000-0000-4000-8000-00000000e004'; + +const _matricula = 'ACS-WIPE-EP-001'; +const _senha = 'senha-sintetica-de-teste'; +const _aparelhoD = 'aparelho-wipe-d'; +const _aparelhoNovo = 'aparelho-wipe-novo'; + +Future _seed(Session session) async { + await Ubs.db.insertRow( + session, + Ubs( + id: UuidValue.fromString(_ubsId), + name: 'UBS Wipe', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_microAreaId), + name: 'Microárea Wipe', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + ); + final now = DateTime.now().toUtc(); + Future user(String id, UserRole role, String cpf) => User.db.insertRow( + session, + User( + id: UuidValue.fromString(id), + cpfHash: cpf, + name: 'Usuário sintético', + birthDate: DateTime.utc(1980), + role: role, + microAreaId: UuidValue.fromString(_microAreaId), + createdAt: now, + updatedAt: now, + ), + ); + await user(_acsId, UserRole.acs, 'development-acs-wipe-ep'); + await user(_adminId, UserRole.admin, 'development-admin-wipe-ep'); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_acsId), + enrollmentId: _matricula, + ubsId: UuidValue.fromString(_ubsId), + active: true, + ), + ); +} + +String _tokenAdmin() => AlertRuntime.instance.auth.issueToken( + AuthenticatedUser( + id: _adminId, + role: UserRole.admin, + microAreaId: null, + deviceId: 'sem-aparelho', + ), +); + +void main() { + withServerpod('Dado o aparelho do ACS com pedido de limpeza remota (#81)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + late String refreshToken; + late String uploadToken; + + setUp(() async { + session = sessionBuilder.build(); + await _seed(session); + await AlertRuntimeHarness.store(session).saveCredential( + _acsId, + await AlertRuntimeHarness.hasher.derive(_senha), + DateTime.now().toUtc(), + ); + final login = await endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + deviceId: _aparelhoD, + ); + refreshToken = login.refreshToken!; + uploadToken = login.uploadToken!; + }); + + Future pedir() async { + final r = await endpoints.admin.orderDeviceWipe( + sessionBuilder, + accessToken: _tokenAdmin(), + acsId: _acsId, + deviceId: _aparelhoD, + reason: DeviceWipeReason.lost, + ); + return r.orderId; + } + + test( + 'a renovação recebe DeviceWipeRequiredException, não SessionExpiredException', + () async { + final orderId = await pedir(); + await expectLater( + endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: refreshToken, + deviceId: _aparelhoD, + ), + throwsA( + isA().having( + (e) => e.orderId, + 'orderId', + orderId, + ), + ), + ); + }, + ); + + test( + 'o login recusa o aparelho marcado, mesmo com a senha certa', + () async { + final orderId = await pedir(); + await expectLater( + endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + deviceId: _aparelhoD, + ), + throwsA( + isA().having( + (e) => e.orderId, + 'orderId', + orderId, + ), + ), + ); + final novo = await endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + deviceId: _aparelhoNovo, + ); + expect(novo.accessToken, isNotEmpty); + }, + ); + + test('o envio diferido do aparelho marcado é recusado', () async { + await pedir(); + await expectLater( + endpoints.visits.syncDeferred( + sessionBuilder, + uploadToken: uploadToken, + deviceId: _aparelhoD, + visits: [], + ), + throwsA(isA()), + ); + }); + + test('entrega, confirmação e auditoria do ciclo inteiro', () async { + expect( + await endpoints.devices.pendingWipe(sessionBuilder, _aparelhoD), + isNull, + ); + final orderId = await pedir(); + + expect( + await endpoints.devices.pendingWipe(sessionBuilder, _aparelhoD), + orderId, + ); + expect( + await endpoints.devices.pendingWipe(sessionBuilder, _aparelhoD), + orderId, + ); + + // Par errado: no-op silencioso, o pedido segue pendente. + await endpoints.devices.confirmWipe( + sessionBuilder, + _aparelhoD, + '00000000-0000-4000-8000-00000000e0ff', + ); + expect( + await endpoints.devices.pendingWipe(sessionBuilder, _aparelhoD), + orderId, + ); + + await endpoints.devices.confirmWipe(sessionBuilder, _aparelhoD, orderId); + await endpoints.devices.confirmWipe(sessionBuilder, _aparelhoD, orderId); + expect( + await endpoints.devices.pendingWipe(sessionBuilder, _aparelhoD), + isNull, + ); + + // O id antigo fica marcado para sempre (D1). + await expectLater( + endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + deviceId: _aparelhoD, + ), + throwsA(isA()), + ); + + final linhas = await AuditLog.db.find( + session, + where: (t) => t.resourceId.equals(UuidValue.fromString(orderId)), + ); + final resultados = linhas.map((l) => l.result).toList()..sort(); + expect(resultados, ['wipe_confirmed', 'wipe_delivered', 'wipe_ordered']); + final doAparelho = linhas.where((l) => l.resourceType == 'acs_device'); + expect(doAparelho.map((l) => l.userId.toString()), everyElement(_acsId)); + expect(doAparelho.map((l) => l.actionType), everyElement('write')); + }); + }); +} 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 77d8607..d19aa4b 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 @@ -2028,6 +2028,72 @@ class _DevicesEndpoint { } }); } + + _i3.Future pendingWipe( + _i1.TestSessionBuilder sessionBuilder, + String deviceId, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'devices', + method: 'pendingWipe', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'devices', + methodName: 'pendingWipe', + parameters: _i1.testObjectToJson({'deviceId': deviceId}), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future confirmWipe( + _i1.TestSessionBuilder sessionBuilder, + String deviceId, + String orderId, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'devices', + method: 'confirmWipe', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'devices', + methodName: 'confirmWipe', + parameters: _i1.testObjectToJson({ + 'deviceId': deviceId, + 'orderId': orderId, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } } class _HealthEndpoint { diff --git a/backend/sinalacs_server/test/unit/device_wipe_service_test.dart b/backend/sinalacs_server/test/unit/device_wipe_service_test.dart index a84361e..7e91171 100644 --- a/backend/sinalacs_server/test/unit/device_wipe_service_test.dart +++ b/backend/sinalacs_server/test/unit/device_wipe_service_test.dart @@ -98,7 +98,9 @@ class _Aparelhos implements DeviceWipeStore { final nova = DeviceWipeOrder( id: UuidValue.fromString('00000000-0000-4000-8000-00000000c001'), deviceId: deviceId, - targetUserId: UuidValue.fromString('00000000-0000-4000-8000-00000000c002'), + targetUserId: UuidValue.fromString( + '00000000-0000-4000-8000-00000000c002', + ), requestedBy: UuidValue.fromString('00000000-0000-4000-8000-00000000c003'), reason: reason, requestedAt: at, @@ -107,17 +109,45 @@ class _Aparelhos implements DeviceWipeStore { return (order: nova, created: true); } + DeviceWipeOrder? pedido; + bool entregue = false; + bool confirmado = false; + int deliveredCalls = 0; + int confirmedCalls = 0; + @override - Future orderFor(String deviceId) => - throw UnimplementedError(); + Future orderFor(String deviceId) async { + final p = pedido; + if (p == null || p.deviceId != deviceId) return null; + return p.copyWith( + deliveredAt: entregue ? DateTime.utc(2026, 10, 17, 13) : null, + confirmedAt: confirmado ? DateTime.utc(2026, 10, 17, 14) : null, + ); + } @override - Future markDelivered(String orderId, DateTime at) => - throw UnimplementedError(); + Future markDelivered(String orderId, DateTime at) async { + deliveredCalls++; + if (entregue) return false; + entregue = true; + return true; + } @override - Future markConfirmed(String deviceId, String orderId, DateTime at) => - throw UnimplementedError(); + Future markConfirmed( + String deviceId, + String orderId, + DateTime at, + ) async { + confirmedCalls++; + final p = pedido; + if (p == null || p.deviceId != deviceId || p.id.toString() != orderId) { + return false; + } + if (confirmado) return false; + confirmado = true; + return true; + } } class _Audit extends AuditTrail { @@ -125,7 +155,9 @@ class _Audit extends AuditTrail { @override Future record(AuditEvent event) async { - _ordem.add('audit:${event.actionType}:${event.resourceType}:${event.result}'); + _ordem.add( + 'audit:${event.actionType}:${event.resourceType}:${event.result}', + ); events.add(event); } } @@ -282,4 +314,101 @@ void main() { expect(audit.events.single.result, 'denied'); expect(_ordem, isNot(contains('store:devicesOf'))); }); + + group('lado do aparelho', () { + const idPedido = '00000000-0000-4000-8000-00000000c001'; + const aparelho = 'aaaaaaaa-1111'; + + void haPedido() { + aparelhos.pedido = DeviceWipeOrder( + id: UuidValue.fromString(idPedido), + deviceId: aparelho, + targetUserId: UuidValue.fromString( + '00000000-0000-4000-8000-00000000c002', + ), + requestedBy: UuidValue.fromString( + '00000000-0000-4000-8000-00000000c003', + ), + reason: DeviceWipeReason.lost, + requestedAt: DateTime.utc(2026, 10, 17, 12), + ); + } + + test('pendingOrderId marca entrega e audita só na primeira vez', () async { + haPedido(); + expect(await servico.pendingOrderId(aparelho), idPedido); + expect(await servico.pendingOrderId(aparelho), idPedido); + expect(audit.events, hasLength(1)); + final e = audit.events.single; + expect(e.userId, '00000000-0000-4000-8000-00000000c002'); + expect(e.actionType, 'write'); + expect(e.resourceType, 'acs_device'); + expect(e.resourceId, idPedido); + expect(e.result, 'wipe_delivered'); + }); + + test( + 'pendingOrderId de aparelho sem pedido, ou já confirmado, devolve null sem auditar', + () async { + expect(await servico.pendingOrderId(aparelho), isNull); + haPedido(); + aparelhos.confirmado = true; + expect(await servico.pendingOrderId(aparelho), isNull); + expect(audit.events, isEmpty); + expect(aparelhos.deliveredCalls, 0); + }, + ); + + test( + 'confirm com par errado não grava nem audita; com par certo grava uma vez', + () async { + haPedido(); + await servico.confirm(aparelho, '00000000-0000-4000-8000-0000000000ff'); + await servico.confirm('outro-aparelho', idPedido); + expect(aparelhos.confirmado, isFalse); + expect(audit.events, isEmpty); + + await servico.confirm(aparelho, idPedido); + await servico.confirm(aparelho, idPedido); + expect(aparelhos.confirmado, isTrue); + final e = audit.events.single; + expect(e.result, 'wipe_confirmed'); + expect(e.resourceType, 'acs_device'); + expect(e.actionType, 'write'); + expect(e.resourceId, idPedido); + expect(e.userId, '00000000-0000-4000-8000-00000000c002'); + }, + ); + + test( + 'ensureDeviceNotMarked lança com o orderId; sentinela e vazio passam', + () async { + await servico.ensureDeviceNotMarked(aparelho); + haPedido(); + await expectLater( + servico.ensureDeviceNotMarked(aparelho), + throwsA( + isA() + .having((e) => e.orderId, 'orderId', idPedido) + .having( + (e) => e.message, + 'message', + 'Este aparelho precisa ser limpo a pedido da coordenação.', + ), + ), + ); + // Confirmado também continua marcado: o id antigo nunca volta (D1). + aparelhos.confirmado = true; + await expectLater( + servico.ensureDeviceNotMarked(aparelho), + throwsA(isA()), + ); + await servico.ensureDeviceNotMarked(null); + await servico.ensureDeviceNotMarked(''); + await servico.ensureDeviceNotMarked( + InstitutionalAuthService.deviceIdAbsent, + ); + }, + ); + }); } diff --git a/backend/sinalacs_server/test/unit/endpoint_auth_posture_test.dart b/backend/sinalacs_server/test/unit/endpoint_auth_posture_test.dart index a7bd74d..edfc822 100644 --- a/backend/sinalacs_server/test/unit/endpoint_auth_posture_test.dart +++ b/backend/sinalacs_server/test/unit/endpoint_auth_posture_test.dart @@ -66,6 +66,14 @@ const _publicMethodsByDesign = { 'JWT — o token de envio opaco, amarrado ao aparelho, de 7 dias e escopo ' 'único É a credencial; o usuário vem de UploadTokenService.resolve (dono ' 'do token, microárea relida do banco), nunca de parâmetro', + 'DevicesEndpoint.pendingWipe': + 'limpeza remota (#81): o aparelho cujos tokens o pedido revogou não tem ' + 'sessão para apresentar; devolve só o orderId do pedido do próprio ' + 'deviceId, sem identidade', + 'DevicesEndpoint.confirmWipe': + 'limpeza remota (#81): confirmação do aparelho sem sessão; o par ' + '(deviceId, orderId) é a prova, e um par errado ou repetido é um no-op ' + 'silencioso, sem retorno que sirva de oráculo', 'VisitsEndpoint.revokeUploadToken': 'revoga pela posse do token de envio diferido, como AuthEndpoint.logout; ' 'idempotente e sem efeito sobre token desconhecido', From 3832fbf7db1867f42740253810da4da4816e636e Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 13:36:12 -0400 Subject: [PATCH 04/17] =?UTF-8?q?fix(backend):=20carimbo=20da=20limpeza=20?= =?UTF-8?q?remota=20e=20sua=20linha=20de=20auditoria=20na=20mesma=20transa?= =?UTF-8?q?=C3=A7=C3=A3o=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devices/device_wipe_service.dart | 56 ++++++++----- .../database/orm_device_wipe_store.dart | 68 +++++++++++----- .../lib/src/runtime/alert_runtime.dart | 8 +- .../integration/device_wipe_store_test.dart | 78 +++++++++++++++++-- .../test/unit/device_wipe_service_test.dart | 49 +++++++++++- 5 files changed, 210 insertions(+), 49 deletions(-) diff --git a/backend/sinalacs_server/lib/src/application/devices/device_wipe_service.dart b/backend/sinalacs_server/lib/src/application/devices/device_wipe_service.dart index 4e32c0f..3b56f9a 100644 --- a/backend/sinalacs_server/lib/src/application/devices/device_wipe_service.dart +++ b/backend/sinalacs_server/lib/src/application/devices/device_wipe_service.dart @@ -34,12 +34,25 @@ abstract interface class DeviceWipeStore { /// O pedido de limpeza do aparelho, ou `null`. Future orderFor(String deviceId); - /// Carimba `deliveredAt`. `true` só na primeira vez. - Future markDelivered(String orderId, DateTime at); + /// Carimba `deliveredAt`. `true` só na primeira vez. O carimbo e a linha de + /// [audit] vivem numa **única transação**: se a trilha falhar, o carimbo é + /// desfeito e a repetição do aparelho ainda é a "primeira vez". Em `false` + /// nada é auditado. + Future markDelivered( + String orderId, + DateTime at, { + required AuditEvent audit, + }); /// Carimba `confirmedAt`. `true` só na primeira vez e só se o par - /// ([deviceId], [orderId]) for o do pedido. - Future markConfirmed(String deviceId, String orderId, DateTime at); + /// ([deviceId], [orderId]) for o do pedido. Carimbo e [audit] na mesma + /// transação, como em [markDelivered]; em `false` nada é auditado. + Future markConfirmed( + String deviceId, + String orderId, + DateTime at, { + required AuditEvent audit, + }); } /// Limpeza remota do aparelho de um ACS pelo backoffice (issue #81). @@ -156,9 +169,13 @@ class DeviceWipeService { final pedido = await _pedidoDe(deviceId); if (pedido == null || pedido.confirmedAt != null) return null; final orderId = pedido.id.toString(); - if (await store.markDelivered(orderId, _clock().toUtc())) { - await _auditarAparelho(pedido, 'wipe_delivered'); - } + // Carimbo e auditoria na mesma transação do store: uma falha da trilha não + // pode deixar a entrega carimbada sem a linha `wipe_delivered`. + await store.markDelivered( + orderId, + _clock().toUtc(), + audit: _eventoDoAparelho(pedido, 'wipe_delivered'), + ); return orderId; } @@ -168,9 +185,12 @@ class DeviceWipeService { if (deviceId.trim().isEmpty) return; final pedido = await store.orderFor(deviceId); if (pedido == null || pedido.id.toString() != orderId) return; - if (await store.markConfirmed(deviceId, orderId, _clock().toUtc())) { - await _auditarAparelho(pedido, 'wipe_confirmed'); - } + await store.markConfirmed( + deviceId, + orderId, + _clock().toUtc(), + audit: _eventoDoAparelho(pedido, 'wipe_confirmed'), + ); } /// Lança [DeviceWipeRequiredException] quando existe **qualquer** pedido para @@ -196,15 +216,13 @@ class DeviceWipeService { } /// O aparelho age em nome da conta do pedido (`targetUserId`). - Future _auditarAparelho(DeviceWipeOrder pedido, String result) => - audit.record( - AuditEvent( - userId: pedido.targetUserId.toString(), - actionType: 'write', - resourceType: 'acs_device', - resourceId: pedido.id.toString(), - result: result, - ), + AuditEvent _eventoDoAparelho(DeviceWipeOrder pedido, String result) => + AuditEvent( + userId: pedido.targetUserId.toString(), + actionType: 'write', + resourceType: 'acs_device', + resourceId: pedido.id.toString(), + result: result, ); /// Recusa com linha `denied` ANTES de lançar; a mesma mensagem para "não diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_device_wipe_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_device_wipe_store.dart index 2f77498..8d225a9 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/database/orm_device_wipe_store.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_device_wipe_store.dart @@ -1,6 +1,9 @@ import 'package:serverpod/serverpod.dart'; import 'package:sinalacs_server/src/application/devices/device_wipe_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_data_subject_case_store.dart' + show AuditAppender; /// Pedidos de limpeza remota e aparelhos do ACS sobre Postgres (issue #81). /// @@ -8,10 +11,18 @@ import 'package:sinalacs_server/src/generated/protocol.dart'; /// junta duas tabelas de token e o pedido. Nada aqui concatena valor no texto /// da consulta. class OrmDeviceWipeStore implements DeviceWipeStore { - OrmDeviceWipeStore({required Session Function() session}) : _session = session; + OrmDeviceWipeStore({ + required Session Function() session, + required AuditAppender appendAudit, + }) : _session = session, + _appendAudit = appendAudit; final Session Function() _session; + /// Onde a linha do aparelho é auditada, dentro da transação do carimbo. Em + /// produção é `OrmAuditTrail.recordInTransaction`; o teste injeta uma falha. + final AuditAppender _appendAudit; + @override Future> devicesOf(String acsId) async { if (!_uuidValido(acsId)) return const []; @@ -124,33 +135,50 @@ class OrmDeviceWipeStore implements DeviceWipeStore { ); @override - Future markDelivered(String orderId, DateTime at) async { + Future markDelivered( + String orderId, + DateTime at, { + required AuditEvent audit, + }) async { if (!_uuidValido(orderId)) return false; - final linhas = await _session().db.unsafeExecute( - 'UPDATE device_wipe_orders SET "deliveredAt" = @at ' - 'WHERE id = @id::uuid AND "deliveredAt" IS NULL', - parameters: QueryParameters.named({'at': at.toUtc(), 'id': orderId}), - ); - return linhas == 1; + final sessao = _session(); + return sessao.db.transaction((transaction) async { + final linhas = await sessao.db.unsafeExecute( + 'UPDATE device_wipe_orders SET "deliveredAt" = @at ' + 'WHERE id = @id::uuid AND "deliveredAt" IS NULL', + parameters: QueryParameters.named({'at': at.toUtc(), 'id': orderId}), + transaction: transaction, + ); + if (linhas != 1) return false; + await _appendAudit(sessao, transaction, audit); + return true; + }); } @override Future markConfirmed( String deviceId, String orderId, - DateTime at, - ) async { + DateTime at, { + required AuditEvent audit, + }) async { if (!_uuidValido(orderId)) return false; - final linhas = await _session().db.unsafeExecute( - 'UPDATE device_wipe_orders SET "confirmedAt" = @at ' - 'WHERE id = @id::uuid AND "deviceId" = @d AND "confirmedAt" IS NULL', - parameters: QueryParameters.named({ - 'at': at.toUtc(), - 'id': orderId, - 'd': deviceId, - }), - ); - return linhas == 1; + final sessao = _session(); + return sessao.db.transaction((transaction) async { + final linhas = await sessao.db.unsafeExecute( + 'UPDATE device_wipe_orders SET "confirmedAt" = @at ' + 'WHERE id = @id::uuid AND "deviceId" = @d AND "confirmedAt" IS NULL', + parameters: QueryParameters.named({ + 'at': at.toUtc(), + 'id': orderId, + 'd': deviceId, + }), + transaction: transaction, + ); + if (linhas != 1) return false; + await _appendAudit(sessao, transaction, audit); + return true; + }); } static bool _uuidValido(String id) { diff --git a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart index e5ee146..2520847 100644 --- a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart +++ b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart @@ -379,7 +379,13 @@ class AlertRuntime { /// requisição. O escopo e a existência do ACS vêm do mesmo store de contas da /// #43; a trilha é a da requisição. DeviceWipeService deviceWipeServiceFor(Session session) => DeviceWipeService( - store: OrmDeviceWipeStore(session: () => session), + store: OrmDeviceWipeStore( + session: () => session, + appendAudit: OrmAuditTrail( + session: () => session, + chainSecret: config.auditChainSecret, + ).recordInTransaction, + ), accounts: OrmAdminAccountStore(session: () => session), audit: auditTrailFor(session), ); diff --git a/backend/sinalacs_server/test/integration/device_wipe_store_test.dart b/backend/sinalacs_server/test/integration/device_wipe_store_test.dart index f66e4a5..eac28ee 100644 --- a/backend/sinalacs_server/test/integration/device_wipe_store_test.dart +++ b/backend/sinalacs_server/test/integration/device_wipe_store_test.dart @@ -1,4 +1,5 @@ import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_device_wipe_store.dart'; import 'package:test/test.dart'; @@ -18,6 +19,13 @@ const _aparelhoE = 'eeeeeeee-aparelho-e'; final _t = DateTime.utc(2026, 10, 17, 9); +final _ev = AuditEvent( + userId: _u1, + actionType: 'write', + resourceType: 'acs_device', + result: 'teste', +); + var _seq = 0; UuidValue _id() => UuidValue.fromString( '00000000-0000-4000-8000-${(0xd100 + (++_seq)).toRadixString(16).padLeft(12, '0')}', @@ -85,9 +93,19 @@ void main() { ) { late Session session; late OrmDeviceWipeStore store; + late List auditados; + var falhaNaTrilha = false; setUp(() async { session = sessionBuilder.build(); - store = OrmDeviceWipeStore(session: () => session); + auditados = []; + falhaNaTrilha = false; + store = OrmDeviceWipeStore( + session: () => session, + appendAudit: (s, transaction, event) async { + if (falhaNaTrilha) throw StateError('trilha fora do ar'); + auditados.add(event); + }, + ); await _usuario(session, _u1, UserRole.acs); await _usuario(session, _u2, UserRole.acs); await _usuario(session, _u3, UserRole.acs); @@ -162,19 +180,67 @@ void main() { expect(lista.single.wipeState, DeviceWipeState.pending); expect(lista.single.wipeRequestedAt, pedido.order.requestedAt); - expect(await store.markDelivered(id, _t.add(const Duration(days: 2))), isTrue); - expect(await store.markDelivered(id, _t.add(const Duration(days: 3))), isFalse); + expect(await store.markDelivered(id, _t.add(const Duration(days: 2)), audit: _ev), isTrue); + expect(await store.markDelivered(id, _t.add(const Duration(days: 3)), audit: _ev), isFalse); lista = await store.devicesOf(_u1); expect(lista.single.wipeState, DeviceWipeState.delivered); - expect(await store.markConfirmed(_aparelhoE, id, _t), isFalse); - expect(await store.markConfirmed(_aparelhoD, id, _t.add(const Duration(days: 4))), isTrue); - expect(await store.markConfirmed(_aparelhoD, id, _t.add(const Duration(days: 5))), isFalse); + expect(await store.markConfirmed(_aparelhoE, id, _t, audit: _ev), isFalse); + expect(await store.markConfirmed(_aparelhoD, id, _t.add(const Duration(days: 4)), audit: _ev), isTrue); + expect(await store.markConfirmed(_aparelhoD, id, _t.add(const Duration(days: 5)), audit: _ev), isFalse); lista = await store.devicesOf(_u1); expect(lista.single.wipeState, DeviceWipeState.confirmed); expect(lista.single.wipeConfirmedAt, _t.add(const Duration(days: 4))); expect((await store.orderFor(_aparelhoD))!.id.toString(), id); expect(await store.orderFor(_aparelhoE), isNull); + // Só as duas primeiras gravações auditam; repetição e par errado não. + expect(auditados, hasLength(2)); + }, + ); + + test( + 'se a trilha falha, o carimbo é desfeito e a repetição ainda audita', + () async { + final id = (await pedir()).order.id.toString(); + + falhaNaTrilha = true; + await expectLater( + store.markDelivered(id, _t.add(const Duration(days: 2)), audit: _ev), + throwsA(isA()), + ); + expect((await store.orderFor(_aparelhoD))!.deliveredAt, isNull); + falhaNaTrilha = false; + expect( + await store.markDelivered( + id, + _t.add(const Duration(days: 2)), + audit: _ev, + ), + isTrue, + ); + + falhaNaTrilha = true; + await expectLater( + store.markConfirmed( + _aparelhoD, + id, + _t.add(const Duration(days: 3)), + audit: _ev, + ), + throwsA(isA()), + ); + expect((await store.orderFor(_aparelhoD))!.confirmedAt, isNull); + falhaNaTrilha = false; + expect( + await store.markConfirmed( + _aparelhoD, + id, + _t.add(const Duration(days: 3)), + audit: _ev, + ), + isTrue, + ); + expect(auditados, hasLength(2)); }, ); diff --git a/backend/sinalacs_server/test/unit/device_wipe_service_test.dart b/backend/sinalacs_server/test/unit/device_wipe_service_test.dart index 7e91171..453995e 100644 --- a/backend/sinalacs_server/test/unit/device_wipe_service_test.dart +++ b/backend/sinalacs_server/test/unit/device_wipe_service_test.dart @@ -125,10 +125,19 @@ class _Aparelhos implements DeviceWipeStore { ); } + /// Como o store ORM: carimbo e auditoria atômicos. Se a trilha lança, o + /// carimbo não fica. + AuditTrail? trilha; + @override - Future markDelivered(String orderId, DateTime at) async { + Future markDelivered( + String orderId, + DateTime at, { + required AuditEvent audit, + }) async { deliveredCalls++; if (entregue) return false; + await trilha?.record(audit); entregue = true; return true; } @@ -137,14 +146,16 @@ class _Aparelhos implements DeviceWipeStore { Future markConfirmed( String deviceId, String orderId, - DateTime at, - ) async { + DateTime at, { + required AuditEvent audit, + }) async { confirmedCalls++; final p = pedido; if (p == null || p.deviceId != deviceId || p.id.toString() != orderId) { return false; } if (confirmado) return false; + await trilha?.record(audit); confirmado = true; return true; } @@ -152,9 +163,11 @@ class _Aparelhos implements DeviceWipeStore { class _Audit extends AuditTrail { final events = []; + bool falhar = false; @override Future record(AuditEvent event) async { + if (falhar) throw StateError('trilha fora do ar'); _ordem.add( 'audit:${event.actionType}:${event.resourceType}:${event.result}', ); @@ -180,6 +193,7 @@ void main() { contas = _Contas(); aparelhos = _Aparelhos(); audit = _Audit(); + aparelhos.trilha = audit; servico = DeviceWipeService( store: aparelhos, accounts: contas, @@ -380,6 +394,35 @@ void main() { }, ); + test( + 'trilha que falha na 1a entrega ou confirmação não perde a linha na repetição', + () async { + haPedido(); + audit.falhar = true; + await expectLater( + servico.pendingOrderId(aparelho), + throwsA(isA()), + ); + expect(aparelhos.entregue, isFalse); + audit.falhar = false; + expect(await servico.pendingOrderId(aparelho), idPedido); + expect(audit.events.map((e) => e.result), ['wipe_delivered']); + + audit.falhar = true; + await expectLater( + servico.confirm(aparelho, idPedido), + throwsA(isA()), + ); + expect(aparelhos.confirmado, isFalse); + audit.falhar = false; + await servico.confirm(aparelho, idPedido); + expect(audit.events.map((e) => e.result), [ + 'wipe_delivered', + 'wipe_confirmed', + ]); + }, + ); + test( 'ensureDeviceNotMarked lança com o orderId; sentinela e vazio passam', () async { From d010db961105ef8096f5a4fb7e647cbcd8826a09 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 14:05:32 -0400 Subject: [PATCH 05/17] feat(acs): limpar o aparelho apaga o banco e troca a chave do SQLCipher (#81, CRIPTO-APP-CACHE) --- .../encrypted_storage_test.dart | 31 +++++++ apps/acs/lib/app/app.dart | 4 + .../core/database/sqlcipher_visit_store.dart | 29 +++++++ .../core/services/offline_visit_queue.dart | 13 +++ .../acs/test/deferred_flush_service_test.dart | 6 ++ apps/acs/test/device_wipe_test.dart | 87 +++++++++++++++++++ apps/acs/test/visit_owner_flow_test.dart | 3 + 7 files changed, 173 insertions(+) diff --git a/apps/acs/integration_test/encrypted_storage_test.dart b/apps/acs/integration_test/encrypted_storage_test.dart index 8c16336..1f29b86 100644 --- a/apps/acs/integration_test/encrypted_storage_test.dart +++ b/apps/acs/integration_test/encrypted_storage_test.dart @@ -391,4 +391,35 @@ void main() { await storage.close(); await EncryptedLocalDatabase.deleteDatabaseFile(databaseName); }); + test('destroy troca a chave no aparelho', () async { + final chaveAntiga = generateDatabaseKey(); + final keyStore = InMemoryDatabaseKeyStore(initialKey: chaveAntiga); + final storage = SqlCipherVisitStorage(keyStore: keyStore, databaseName: databaseName); + await storage.forOwner('acs-teste').save([ + OfflineVisitRecord(patientId: seedPatientId, risk: 'red', status: 'PENDENTE', outcome: marcador), + ]); + + await storage.destroyAll(); + expect(await keyStore.readOrCreate(), isNot(chaveAntiga), reason: 'a chave não foi trocada'); + + // Reabre com a chave nova: banco vazio. + expect(await storage.forOwner('acs-teste').load(), isEmpty); + await storage.forOwner('acs-teste').save([ + OfflineVisitRecord(patientId: seedPatientId, risk: 'green', status: 'PENDENTE'), + ]); + await storage.close(); + + final bytes = await File(await EncryptedLocalDatabase.pathFor(databaseName)).readAsBytes(); + final conteudo = String.fromCharCodes(bytes); + expect(conteudo.contains(marcador), isFalse, reason: 'a visita antiga sobrou no arquivo'); + expect(String.fromCharCodes(bytes.take(15)), isNot('SQLite format 3')); + + // A chave antiga não abre o arquivo novo. + expect( + () => EncryptedLocalDatabase.open(databaseName: databaseName, passphrase: chaveAntiga), + throwsA(anything), + ); + + await EncryptedLocalDatabase.deleteDatabaseFile(databaseName); + }); } diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index 5d0ee87..0ba7aa1 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -432,6 +432,10 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i // Reconta e apaga numa transação; recusa sem apagar nada se sobrou visita. await _storage.wipeAllData(); + // Só depois da recusa acima: sem visita nenhuma, o arquivo e a chave saem e + // o próximo uso abre um banco novo com chave nova (CRIPTO-APP-CACHE). + await _storage.destroyAll(); + final tokens = widget.uploadTokens; if (tokens != null) { Set owners; diff --git a/apps/acs/lib/core/database/sqlcipher_visit_store.dart b/apps/acs/lib/core/database/sqlcipher_visit_store.dart index a1d4cd7..6e69e04 100644 --- a/apps/acs/lib/core/database/sqlcipher_visit_store.dart +++ b/apps/acs/lib/core/database/sqlcipher_visit_store.dart @@ -153,6 +153,32 @@ class VisitDatabase { // também sai. await EncryptedLocalDatabase.deleteRecoveryCopy(databaseName); } + + /// Destrói o banco e a chave (CRIPTO-APP-CACHE, D6): fecha a conexão, apaga o + /// arquivo com WAL/SHM/journal, apaga a cópia `.recuperado` e apaga a + /// chave do Keystore. O próximo [open] cria uma chave NOVA e um banco vazio + /// no schema atual: é assim que a chave é trocada, por evento (limpeza do + /// aparelho, limpeza remota), sem `PRAGMA rekey` sobre um arquivo com visitas + /// pendentes. + /// + /// Incondicional: NÃO confere visitas, isso é de quem chama + /// ([wipeAllData] recusa antes). Espera uma abertura em voo terminar para não + /// deixar uma conexão viva sobre o arquivo apagado. O objeto continua + /// reutilizável. A chave nunca é lida nem registrada aqui. + Future destroy() async { + final inFlight = _opening; + if (inFlight != null) { + try { + await inFlight; + } catch (_) { + // Abertura que falhou não deixa conexão: o que importa é apagar. + } + } + await close(); + await EncryptedLocalDatabase.deleteDatabaseFile(databaseName); + await EncryptedLocalDatabase.deleteRecoveryCopy(databaseName); + await _keyStore.delete(); + } } /// O erro que o SQLCipher dá quando a chave não abre o arquivo. @@ -236,6 +262,9 @@ class SqlCipherVisitStorage implements VisitStorage { @override Future wipeAllData() => database.wipeAllData(); + @override + Future destroyAll() => database.destroy(); + Future close() => database.close(); } diff --git a/apps/acs/lib/core/services/offline_visit_queue.dart b/apps/acs/lib/core/services/offline_visit_queue.dart index aebeb4f..a54b8c8 100644 --- a/apps/acs/lib/core/services/offline_visit_queue.dart +++ b/apps/acs/lib/core/services/offline_visit_queue.dart @@ -268,6 +268,12 @@ abstract interface class VisitStorage { /// apagar nada, se houver QUALQUER visita no aparelho, de qualquer dono ou /// da quarentena: tudo o que está em `offline_visits` ainda não subiu. Future wipeAllData(); + + /// Troca de chave por evento (CRIPTO-APP-CACHE): apaga o banco inteiro e a + /// chave que o cifrava; a próxima abertura recomeça com chave nova. É + /// **incondicional** (não confere visitas): quem chama recusa antes, como + /// [wipeAllData] faz com [WipeBlocked]. + Future destroyAll(); } /// "Limpar este aparelho" recusado: ainda há [unsent] visitas não enviadas. @@ -359,6 +365,13 @@ class InMemoryVisitStorage implements VisitStorage { if (unsent > 0) throw WipeBlocked(unsent); _byOwner.clear(); } + + /// Sem arquivo nem chave: esvazia a memória. + @override + Future destroyAll() async { + _byOwner.clear(); + _legacy._visits.clear(); + } } class _InMemoryOwnerVisitStore implements VisitStore { diff --git a/apps/acs/test/deferred_flush_service_test.dart b/apps/acs/test/deferred_flush_service_test.dart index 82117f9..020628f 100644 --- a/apps/acs/test/deferred_flush_service_test.dart +++ b/apps/acs/test/deferred_flush_service_test.dart @@ -493,6 +493,9 @@ class _RemocaoLegadaFalhaUmaVez implements VisitStorage { @override Future wipeAllData() => _inner.wipeAllData(); + + @override + Future destroyAll() => _inner.destroyAll(); } class _LegadoFalha implements LegacyVisitStore { @@ -533,6 +536,9 @@ class _GravacaoDoDonoFalhaUmaVez implements VisitStorage { @override Future wipeAllData() => _inner.wipeAllData(); + + @override + Future destroyAll() => _inner.destroyAll(); } class _DonoFalha implements VisitStore { diff --git a/apps/acs/test/device_wipe_test.dart b/apps/acs/test/device_wipe_test.dart index be1eb39..9cea9cb 100644 --- a/apps/acs/test/device_wipe_test.dart +++ b/apps/acs/test/device_wipe_test.dart @@ -79,6 +79,54 @@ void main() { setUp(() => EncryptedLocalDatabase.deleteDatabaseFile(dbName)); tearDown(() => EncryptedLocalDatabase.deleteDatabaseFile(dbName)); + test('destroy apaga o arquivo, a cópia recuperada e troca a chave', () async { + final chaveAntiga = 'a' * 64; + final keyStore = InMemoryDatabaseKeyStore(initialKey: chaveAntiga); + final storage = SqlCipherVisitStorage(keyStore: keyStore, databaseName: dbName, allowUnencryptedForTesting: true); + addTearDown(storage.close); + await gravarCursorECache(storage); + final caminho = await EncryptedLocalDatabase.pathFor(dbName); + final copia = File('$caminho${EncryptedLocalDatabase.recoverySuffix}')..writeAsStringSync('cifrado'); + expect(File(caminho).existsSync(), isTrue); + + await storage.destroyAll(); + + expect(File(caminho).existsSync(), isFalse); + for (final sufixo in ['-wal', '-shm', '-journal']) { + expect(File('$caminho$sufixo').existsSync(), isFalse); + } + expect(copia.existsSync(), isFalse); + expect(await keyStore.readOrCreate(), isNot(chaveAntiga)); + }); + + test('open depois de destroy devolve banco vazio no schema atual', () async { + final keyStore = InMemoryDatabaseKeyStore(initialKey: 'a' * 64); + final storage = SqlCipherVisitStorage(keyStore: keyStore, databaseName: dbName, allowUnencryptedForTesting: true); + addTearDown(storage.close); + await gravarCursorECache(storage); + + await storage.database.destroy(); + final db = await storage.database.open(); + + expect((await db.rawQuery('PRAGMA user_version')).single['user_version'], EncryptedLocalDatabase.schemaVersion); + expect(await contarTabelas(storage), {for (final t in tabelas) t: 0}); + }); + + test('destroy espera uma abertura em voo e deixa o objeto reutilizável', () async { + final keyStore = InMemoryDatabaseKeyStore(initialKey: 'a' * 64); + final storage = SqlCipherVisitStorage(keyStore: keyStore, databaseName: dbName, allowUnencryptedForTesting: true); + addTearDown(storage.close); + + final abrindo = storage.database.open(); + await storage.database.destroy(); + await abrindo; + + final caminho = await EncryptedLocalDatabase.pathFor(dbName); + expect(File(caminho).existsSync(), isFalse, reason: 'a abertura em voo não sobrevive ao destroy'); + expect(await contarTabelas(storage), {for (final t in tabelas) t: 0}); + expect(await keyStore.readOrCreate(), isNot('a' * 64)); + }); + test('recusa com visita de um dono e não apaga nada', () async { final storage = abrirBanco(dbName); addTearDown(storage.close); @@ -648,6 +696,42 @@ void main() { reason: 'nada do território é regravado depois de "dados apagados"'); }); + testWidgets('Limpar este aparelho sem visitas troca a chave', (tester) async { + final chaveAntiga = 'a' * 64; + final keyStore = InMemoryDatabaseKeyStore(initialKey: chaveAntiga); + final comChave = SqlCipherVisitStorage(keyStore: keyStore, databaseName: dbName, allowUnencryptedForTesting: true); + addTearDown(comChave.close); + await io(tester, () => gravarCursorECache(comChave)); + await abrirApp(tester, visitStorage: comChave); + await entrarComo(tester, acsA); + + await tocarEmLimpar(tester); + await tester.tap(find.byKey(const Key('wipe_confirm'))); + await assentar(tester); + await assentar(tester); + + expect(find.text('Os dados deste aparelho foram apagados.'), findsOneWidget); + expect(await io(tester, keyStore.readOrCreate), isNot(chaveAntiga)); + }); + + testWidgets('Limpar este aparelho com visita recusa e mantém arquivo e chave', (tester) async { + final chaveAntiga = 'a' * 64; + final keyStore = InMemoryDatabaseKeyStore(initialKey: chaveAntiga); + final comChave = SqlCipherVisitStorage(keyStore: keyStore, databaseName: dbName, allowUnencryptedForTesting: true); + addTearDown(comChave.close); + await io(tester, () => comChave.forOwner(acsA).save([pendente('a-1')])); + final caminho = await io(tester, () => EncryptedLocalDatabase.pathFor(dbName)); + backend.syncFailure = semRede; + await abrirApp(tester, visitStorage: comChave); + await entrarComo(tester, acsA); + + await tocarEmLimpar(tester); + + expect(find.byKey(const Key('wipe_blocked')), findsOneWidget); + expect(File(caminho).existsSync(), isTrue); + expect(await io(tester, keyStore.readOrCreate), chaveAntiga); + }); + testWidgets('com TUDO enviado: confirma, esvazia as tabelas e as chaves de envio, sai e volta ao login', (tester) async { await io(tester, () async { await storage.forOwner(acsA).save([pendente('a-1')]); @@ -705,6 +789,9 @@ class _GravacaoDeAFalha implements VisitStorage { @override Future wipeAllData() => _inner.wipeAllData(); + @override + Future destroyAll() => _inner.destroyAll(); + @override VisitStore forOwner(String ownerId) => ownerId == 'acs-a' ? FailingVisitStore(failOnLoad: false) : _inner.forOwner(ownerId); diff --git a/apps/acs/test/visit_owner_flow_test.dart b/apps/acs/test/visit_owner_flow_test.dart index b474004..ebd9b85 100644 --- a/apps/acs/test/visit_owner_flow_test.dart +++ b/apps/acs/test/visit_owner_flow_test.dart @@ -549,6 +549,9 @@ class _GravacaoDeAFalha implements VisitStorage { @override Future wipeAllData() => _inner.wipeAllData(); + @override + Future destroyAll() => _inner.destroyAll(); + @override VisitStore forOwner(String ownerId) => ownerId == 'acs-a' ? FailingVisitStore(failOnLoad: false) : _inner.forOwner(ownerId); From 68357b018defe754543fcac033f9e5e7d496cb40 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 14:19:23 -0400 Subject: [PATCH 06/17] fix(acs): destroy do banco exclusivo contra open concorrente (#81) --- .../core/database/sqlcipher_visit_store.dart | 26 ++++++++- apps/acs/test/device_wipe_test.dart | 55 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/apps/acs/lib/core/database/sqlcipher_visit_store.dart b/apps/acs/lib/core/database/sqlcipher_visit_store.dart index 6e69e04..7b43aa0 100644 --- a/apps/acs/lib/core/database/sqlcipher_visit_store.dart +++ b/apps/acs/lib/core/database/sqlcipher_visit_store.dart @@ -52,6 +52,7 @@ class VisitDatabase { Database? _database; Future? _opening; + Future? _destroying; /// Abre preguiçosamente, na primeira leitura ou escrita. /// @@ -62,6 +63,12 @@ class VisitDatabase { /// conexões ao mesmo arquivo, ou duas recuperações apagando o arquivo, são /// o que isto evita). Se a abertura falha, o próximo `open()` tenta de novo. Future open() { + // Durante um [destroy] nenhuma abertura começa: ela esperaria pelo fim e + // abriria com a chave NOVA. Sem isso, uma abertura no meio usaria a chave + // velha (ainda guardada) sobre um arquivo apagado ou recém-criado. + final destroying = _destroying; + if (destroying != null) return destroying.then((_) => open()); + final existing = _database; if (existing != null && existing.isOpen) return Future.value(existing); @@ -165,7 +172,24 @@ class VisitDatabase { /// ([wipeAllData] recusa antes). Espera uma abertura em voo terminar para não /// deixar uma conexão viva sobre o arquivo apagado. O objeto continua /// reutilizável. A chave nunca é lida nem registrada aqui. - Future destroy() async { + Future destroy() { + // Exclusivo contra [open] e contra outro destroy: serializa na fila. + final previous = _destroying; + final run = Future(() async { + if (previous != null) { + try { + await previous; + } catch (_) {} + } + await _destroyOnce(); + }); + _destroying = run; + return run.whenComplete(() { + if (identical(_destroying, run)) _destroying = null; + }); + } + + Future _destroyOnce() async { final inFlight = _opening; if (inFlight != null) { try { diff --git a/apps/acs/test/device_wipe_test.dart b/apps/acs/test/device_wipe_test.dart index 9cea9cb..c299412 100644 --- a/apps/acs/test/device_wipe_test.dart +++ b/apps/acs/test/device_wipe_test.dart @@ -87,6 +87,9 @@ void main() { await gravarCursorECache(storage); final caminho = await EncryptedLocalDatabase.pathFor(dbName); final copia = File('$caminho${EncryptedLocalDatabase.recoverySuffix}')..writeAsStringSync('cifrado'); + for (final sufixo in ['-wal', '-shm', '-journal']) { + File('$caminho$sufixo').writeAsStringSync('resto'); + } expect(File(caminho).existsSync(), isTrue); await storage.destroyAll(); @@ -127,6 +130,44 @@ void main() { expect(await keyStore.readOrCreate(), isNot('a' * 64)); }); + test('open durante destroy espera o fim e abre com a chave NOVA', () async { + final chaveAntiga = 'a' * 64; + final portao = Completer(); + final keyStore = _ChaveComPortao(chaveAntiga, portao.future); + final usadas = []; + final banco = VisitDatabase( + keyStore: keyStore, + databaseName: dbName, + allowUnencryptedForTesting: true, + opener: (nome, chave, claro) { + usadas.add(chave); + return EncryptedLocalDatabase.open( + databaseName: nome, passphrase: chave, allowUnencryptedForTesting: claro); + }, + ); + addTearDown(banco.close); + await banco.open(); + usadas.clear(); + + final destruindo = banco.destroy(); + var abriu = false; + final abrindo = banco.open().then((db) { + abriu = true; + return db; + }); + await Future.delayed(const Duration(milliseconds: 100)); + expect(abriu, isFalse, reason: 'a abertura não pode furar o destroy'); + expect(usadas, isEmpty); + + portao.complete(); + await destruindo; + await abrindo; + + expect(usadas, hasLength(1)); + expect(usadas.single, isNot(chaveAntiga)); + expect(usadas.single, await keyStore.readOrCreate()); + }); + test('recusa com visita de um dono e não apaga nada', () async { final storage = abrirBanco(dbName); addTearDown(storage.close); @@ -796,3 +837,17 @@ class _GravacaoDeAFalha implements VisitStorage { VisitStore forOwner(String ownerId) => ownerId == 'acs-a' ? FailingVisitStore(failOnLoad: false) : _inner.forOwner(ownerId); } + +/// Chave cuja exclusão espera um portão: abre a janela em que um `open()` +/// concorrente ao `destroy()` leria a chave velha. +class _ChaveComPortao extends InMemoryDatabaseKeyStore { + _ChaveComPortao(String chave, this._portao) : super(initialKey: chave); + + final Future _portao; + + @override + Future delete() async { + await _portao; + await super.delete(); + } +} From 3263f98648aad391576fdfc5cef0179e1eba7154 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 14:44:53 -0400 Subject: [PATCH 07/17] =?UTF-8?q?feat(acs):=20coordenador=20da=20limpeza?= =?UTF-8?q?=20remota=20com=20di=C3=A1rio=20e=20confirma=C3=A7=C3=A3o=20(#8?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration_test/full_journey_e2e.dart | 10 + apps/acs/lib/core/network/backend_client.dart | 108 ++++++- .../security/secure_session_token_store.dart | 17 + .../core/security/session_token_store.dart | 7 + apps/acs/lib/core/services/remote_wipe.dart | 233 +++++++++++++ apps/acs/test/backend_client_wipe_test.dart | 124 +++++++ apps/acs/test/refresh_session_test.dart | 3 + apps/acs/test/remote_wipe_test.dart | 306 ++++++++++++++++++ apps/acs/test/support/fake_rpc_server.dart | 31 ++ apps/acs/test/support/fakes.dart | 50 +++ 10 files changed, 880 insertions(+), 9 deletions(-) create mode 100644 apps/acs/lib/core/services/remote_wipe.dart create mode 100644 apps/acs/test/backend_client_wipe_test.dart create mode 100644 apps/acs/test/remote_wipe_test.dart diff --git a/apps/acs/integration_test/full_journey_e2e.dart b/apps/acs/integration_test/full_journey_e2e.dart index 3692d14..4affa77 100644 --- a/apps/acs/integration_test/full_journey_e2e.dart +++ b/apps/acs/integration_test/full_journey_e2e.dart @@ -149,6 +149,16 @@ class _EnvioDeVisitasForaDoAr implements AcsBackend { @override set onSessionExpired(void Function()? value) => _inner.onSessionExpired = value; @override + void Function(String orderId)? get onDeviceWipeRequired => _inner.onDeviceWipeRequired; + @override + set onDeviceWipeRequired(void Function(String orderId)? value) => _inner.onDeviceWipeRequired = value; + @override + Future pendingDeviceWipe(String deviceId) => _inner.pendingDeviceWipe(deviceId); + @override + Future confirmDeviceWipe(String deviceId, String orderId) => _inner.confirmDeviceWipe(deviceId, orderId); + @override + Future forgetLocalSession() => _inner.forgetLocalSession(); + @override Future get hasStoredSession => _inner.hasStoredSession; @override Future unlockStoredSession({required String reason}) => _inner.unlockStoredSession(reason: reason); diff --git a/apps/acs/lib/core/network/backend_client.dart b/apps/acs/lib/core/network/backend_client.dart index cf02b6e..5c98a43 100644 --- a/apps/acs/lib/core/network/backend_client.dart +++ b/apps/acs/lib/core/network/backend_client.dart @@ -38,6 +38,19 @@ class UploadTokenRefused extends BackendFailure { const UploadTokenRefused() : super('Envio não autorizado. Entre novamente.', isRecoverable: false); } +/// O servidor pediu a limpeza remota deste aparelho (#81) e recusou o login, +/// a renovação ou o envio diferido com `DeviceWipeRequiredException`. Não +/// recuperável: nada volta a funcionar com o id antigo da instalação. Quem +/// apaga é o `RemoteWipeCoordinator`, avisado por +/// [AcsBackend.onDeviceWipeRequired] — nunca por `onSessionExpired`. +class DeviceWipeRequiredFailure extends BackendFailure { + const DeviceWipeRequiredFailure(this.orderId) + : super('Este aparelho precisa ser limpo a pedido da coordenação.', isRecoverable: false); + + /// Identificador do pedido, a devolver em `devices.confirmWipe`. + final String orderId; +} + /// O ACS tem MFA ativa e a senha conferiu: falta o código do autenticador. class MfaCodeRequired extends BackendFailure { const MfaCodeRequired() : super('Informe o código do aplicativo autenticador.'); @@ -87,6 +100,26 @@ abstract class AcsBackend { /// volta ao login. void Function()? onSessionExpired; + /// Chamado quando o servidor pede a limpeza remota deste aparelho (#81): + /// `login`, a renovação e o envio diferido recusam com + /// [DeviceWipeRequiredFailure] e passam o `orderId` aqui. É o gatilho do + /// `RemoteWipeCoordinator`; [onSessionExpired] não é chamado nesse caso. + void Function(String orderId)? onDeviceWipeRequired; + + /// `devices.pendingWipe`: o `orderId` da limpeza pedida para [deviceId], ou + /// `null`. Sem token. Falha de rede: [BackendFailure] recuperável. + Future pendingDeviceWipe(String deviceId); + + /// `devices.confirmWipe`: o aparelho avisa que apagou tudo. Sem token e sem + /// retorno; um par errado se comporta igual no servidor. + Future confirmDeviceWipe(String deviceId, String orderId); + + /// Esquece a sessão SÓ neste aparelho, sem rede: torna obsoleta a renovação + /// em voo, descarta a sessão em memória e apaga o refresh token. É o passo + /// de sessão da limpeza remota — o servidor já revogou a família no pedido, + /// então não há `logout` a chamar. + Future forgetLocalSession(); + /// `true` se há um refresh token guardado no aparelho (a UI pode tentar /// [resumeSession] na partida). Future get hasStoredSession; @@ -229,6 +262,12 @@ class MisconfiguredBackend implements AcsBackend { @override set onSessionExpired(void Function()? _) {} + @override + void Function(String orderId)? get onDeviceWipeRequired => null; + + @override + set onDeviceWipeRequired(void Function(String orderId)? _) {} + @override AuthSession? get session => null; @@ -247,6 +286,10 @@ class MisconfiguredBackend implements AcsBackend { @override Future logout() async {} + /// Não há sessão nem token: nada a esquecer. + @override + Future forgetLocalSession() async {} + /// Recusa uma chamada. `Never` avisa o analisador de que nada abaixo disto /// executa, então cada método abaixo é uma linha só. Never _recusar() => throw failure; @@ -301,6 +344,12 @@ class MisconfiguredBackend implements AcsBackend { @override Future revokeUploadToken(String uploadToken) async => _recusar(); + @override + Future pendingDeviceWipe(String deviceId) async => _recusar(); + + @override + Future confirmDeviceWipe(String deviceId, String orderId) async => _recusar(); + @override Future> pullVisits({required DateTime since}) async => _recusar(); @@ -391,6 +440,17 @@ class BackendClient implements AcsBackend { @override void Function()? onSessionExpired; + @override + void Function(String orderId)? onDeviceWipeRequired; + + /// Traduz o pedido de limpeza do servidor e avisa quem apaga. Chamado só + /// FORA de [_exclusive]: o aviso dispara a limpeza, que chama + /// [forgetLocalSession] — outra seção crítica. + DeviceWipeRequiredFailure _wipeRequired(DeviceWipeRequiredException error) { + onDeviceWipeRequired?.call(error.orderId); + return DeviceWipeRequiredFailure(error.orderId); + } + @override bool get isAuthenticated { final current = _session; @@ -428,15 +488,19 @@ class BackendClient implements AcsBackend { String? totpCode, }) async { final deviceId = await _storage(_deviceIds.readOrCreate); - final result = await _guard( - () => _client.auth.loginInstitutional( - matricula: matricula, - password: senha, - totpCode: totpCode, - // O servidor só emite refresh token a quem informa o aparelho. - deviceId: deviceId, - ), - ); + final result = await _guard(() async { + try { + return await _client.auth.loginInstitutional( + matricula: matricula, + password: senha, + totpCode: totpCode, + // O servidor só emite refresh token a quem informa o aparelho. + deviceId: deviceId, + ); + } on DeviceWipeRequiredException catch (error) { + throw _wipeRequired(error); + } + }); final session = AuthSession.tryParse(result.accessToken, result.tokenType); if (session == null) { @@ -581,6 +645,11 @@ class BackendClient implements AcsBackend { return await _client.auth.refreshSession(refreshToken: refreshToken, deviceId: deviceId); } on SessionExpiredException { return null; + } on DeviceWipeRequiredException catch (error) { + // Fora da seção crítica, como a rede. Não é sessão vencida: nada de + // `onSessionExpired` nem de apagar o token aqui — quem apaga tudo é a + // limpeza (que chama `forgetLocalSession`). + throw _wipeRequired(error); } }); // Confirmação dentro da seção: a checagem de obsolescência é definitiva @@ -712,6 +781,16 @@ class BackendClient implements AcsBackend { }); } + /// Como a segunda seção do [logout], sem a chamada de rede: o servidor já + /// revogou a família no pedido de limpeza (#81). Só E/S local dentro da + /// seção crítica. + @override + Future forgetLocalSession() => _exclusive(() async { + _invalidateRenewal(); + _session = null; + await _storage(_tokenStore.clear); + }); + /// Login de desenvolvimento, para `tool/` e `integration_test/`. /// /// É a versão antiga de [login], preservada porque as ferramentas rodam @@ -839,6 +918,10 @@ class BackendClient implements AcsBackend { } on SessionExpiredException { // A recusa única do servidor para o token de envio. throw const UploadTokenRefused(); + } on DeviceWipeRequiredException catch (error) { + // O aparelho inteiro vai ser limpo: não é recusa do token deste + // dono, então NÃO vira [UploadTokenRefused] (que apagaria o token). + throw _wipeRequired(error); } }); @@ -846,6 +929,13 @@ class BackendClient implements AcsBackend { Future revokeUploadToken(String uploadToken) => _guard(() => _client.visits.revokeUploadToken(uploadToken: uploadToken)); + @override + Future pendingDeviceWipe(String deviceId) => _guard(() => _client.devices.pendingWipe(deviceId)); + + @override + Future confirmDeviceWipe(String deviceId, String orderId) => + _guard(() => _client.devices.confirmWipe(deviceId, orderId)); + /// Reconciliação central→dispositivo: visitas da microárea alteradas desde /// `since` (RF15, decisão §5). Espelha [syncVisits] na tradução de falhas. @override diff --git a/apps/acs/lib/core/security/secure_session_token_store.dart b/apps/acs/lib/core/security/secure_session_token_store.dart index 349ddcb..d27a656 100644 --- a/apps/acs/lib/core/security/secure_session_token_store.dart +++ b/apps/acs/lib/core/security/secure_session_token_store.dart @@ -57,6 +57,23 @@ class SecureStorageDeviceIdStore implements DeviceIdStore { Future? _pending; + /// Apaga `acs_device_id` (limpeza remota, #81). Espera a leitura em voo + /// terminar, para ela não regravar o id antigo depois da exclusão, e zera a + /// memória DEPOIS de apagar: uma leitura que tenha começado no meio pode ter + /// visto o id antigo, e não fica memorizada. + @override + Future delete() async { + final inFlight = _pending; + if (inFlight != null) { + try { + await inFlight; + } catch (_) {} + } + _pending = null; + await _storage.delete(key: _key); + _pending = null; + } + Future _load() async { final existing = await _storage.read(key: _key); if (existing != null && existing.isNotEmpty) return existing; diff --git a/apps/acs/lib/core/security/session_token_store.dart b/apps/acs/lib/core/security/session_token_store.dart index e09d853..278468c 100644 --- a/apps/acs/lib/core/security/session_token_store.dart +++ b/apps/acs/lib/core/security/session_token_store.dart @@ -56,6 +56,10 @@ class MemorySessionTokenStore implements SessionTokenStore { /// refresh token a ele e recusa a troca por outro aparelho. abstract interface class DeviceIdStore { Future readOrCreate(); + + /// Apaga o id desta instalação (limpeza remota, #81, D1): o próximo + /// [readOrCreate] gera outro, e o id antigo nunca volta. + Future delete(); } /// UUID v4 aleatório (sem dependência extra). @@ -76,4 +80,7 @@ class MemoryDeviceIdStore implements DeviceIdStore { @override Future readOrCreate() async => _id ??= generateDeviceId(); + + @override + Future delete() async => _id = null; } diff --git a/apps/acs/lib/core/services/remote_wipe.dart b/apps/acs/lib/core/services/remote_wipe.dart new file mode 100644 index 0000000..eb67612 --- /dev/null +++ b/apps/acs/lib/core/services/remote_wipe.dart @@ -0,0 +1,233 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; + +// `shared_preferences` puxa o Flutter: por isso o diário mora aqui, e +// `backend_client.dart` nunca importa este arquivo (guardado por +// `test/session_store_wiring_test.dart`). + +/// Fase do diário da limpeza remota (#81, D7). +/// +/// `erasing`: a limpeza começou e pode ter parado no meio — a partida refaz +/// tudo (o apagador é idempotente). `erased`: o aparelho já está limpo e só +/// falta o servidor receber a confirmação. +enum WipePhase { erasing, erased } + +/// Uma entrada do diário: o pedido e o id ANTIGO da instalação, que é o que o +/// servidor conhece (o apagador troca o id). +typedef WipeJournalEntry = ({String orderId, String deviceId, WipePhase phase}); + +/// Diário da limpeza remota (D7). Gravado ANTES de qualquer exclusão e +/// apagado só depois de `confirmWipe` responder. Guarda só o `orderId` e o +/// `deviceId` antigo: nada sensível. +abstract interface class WipeJournal { + Future read(); + + Future write(String orderId, String deviceId, WipePhase phase); + + Future clear(); +} + +/// Duplo de teste: só em memória. +class MemoryWipeJournal implements WipeJournal { + WipeJournalEntry? _entry; + + @override + Future read() async => _entry; + + @override + Future write(String orderId, String deviceId, WipePhase phase) async => + _entry = (orderId: orderId, deviceId: deviceId, phase: phase); + + @override + Future clear() async => _entry = null; +} + +/// O diário de produção, em `shared_preferences` (`acs_remote_wipe_journal`), +/// como JSON `{orderId, deviceId, phase}`. Fica fora do Keystore e do banco +/// de propósito: os dois são justamente o que a limpeza apaga. +class SharedPreferencesWipeJournal implements WipeJournal { + SharedPreferencesWipeJournal({Future Function()? preferences}) + : _preferences = preferences ?? SharedPreferences.getInstance; + + static const key = 'acs_remote_wipe_journal'; + + final Future Function() _preferences; + + /// Uma entrada ilegível (ou de formato desconhecido) é tratada como + /// ausente: sem `orderId` não há o que confirmar. + @override + Future read() async { + final raw = (await _preferences()).getString(key); + if (raw == null) return null; + try { + final json = jsonDecode(raw) as Map; + final orderId = json['orderId'] as String; + final deviceId = json['deviceId'] as String; + final phase = WipePhase.values.byName(json['phase'] as String); + return (orderId: orderId, deviceId: deviceId, phase: phase); + } catch (_) { + return null; + } + } + + @override + Future write(String orderId, String deviceId, WipePhase phase) async { + final saved = await (await _preferences()) + .setString(key, jsonEncode({'orderId': orderId, 'deviceId': deviceId, 'phase': phase.name})); + if (!saved) throw StateError('O diário da limpeza remota não foi gravado.'); + } + + @override + Future clear() async { + await (await _preferences()).remove(key); + } +} + +/// Apaga tudo o que é do aparelho: banco e chave do SQLCipher, tokens de +/// envio, a sessão local e o id da instalação. Idempotente. Montado pelo app +/// (Task 5) a partir de `VisitStorage.destroyAll`, `UploadTokenStore.clearAll`, +/// `AcsBackend.forgetLocalSession` e `DeviceIdStore.delete`. +typedef DeviceEraser = Future Function(); + +/// Limpeza remota do aparelho do ACS pedida pelo backoffice (#81). +/// +/// Três gatilhos podem chegar juntos — a consulta da partida/retorno/rede +/// ([check]), a recusa do servidor no login, na renovação ou no envio +/// diferido ([apply], via `onDeviceWipeRequired`) e o diário pendente +/// ([resumePending]) — e todos compartilham UMA limpeza (single-flight). +/// +/// Ordem: diário em `erasing` → apagar → diário em `erased` → `onWiped` → +/// `confirmDeviceWipe` com o id ANTIGO → apagar o diário. Sem rede na +/// confirmação, o diário fica em `erased` e a confirmação é reenviada no +/// próximo gatilho. INV-03: nada aqui chama `acknowledge` nem `resolve`; os +/// alertas seguem `pending` no servidor. +class RemoteWipeCoordinator { + RemoteWipeCoordinator({ + required AcsBackend backend, + required DeviceIdStore deviceIds, + required WipeJournal journal, + required DeviceEraser erase, + void Function()? onWiped, + }) : _backend = backend, + _deviceIds = deviceIds, + _journal = journal, + _erase = erase, + _onWiped = onWiped; + + final AcsBackend _backend; + final DeviceIdStore _deviceIds; + final WipeJournal _journal; + final DeviceEraser _erase; + final void Function()? _onWiped; + + Future? _inFlight; + + /// Pedidos já apagados nesta execução do app. Um gatilho atrasado — a + /// consulta que saiu com o id antigo e respondeu depois da limpeza — não + /// apaga de novo o aparelho já limpo (que pode ter um login novo). + final Set _erasedOrders = {}; + + /// Na partida, ANTES de abrir o banco: `erasing` termina a limpeza e + /// confirma; `erased` só reenvia a confirmação. Sem diário, não faz nada. + /// Uma falha do apagador sobe (o diário continua em `erasing`, e a próxima + /// partida tenta de novo); a da confirmação, não. + Future resumePending() => _single(() async { + final entry = await _readJournal(); + if (entry == null) return; + await _run(entry.orderId, entry.deviceId, entry.phase); + }); + + /// Pergunta ao servidor se há limpeza pedida para este aparelho e, se há, + /// aplica. `true` só quando o aparelho foi limpo. Nunca lança: sem rede (ou + /// com falha do apagador) devolve `false`. + Future check() async { + final String? orderId; + try { + final deviceId = await _deviceIds.readOrCreate(); + orderId = await _backend.pendingDeviceWipe(deviceId); + } catch (_) { + return false; + } + if (orderId == null) return false; + try { + await apply(orderId); + return true; + } catch (_) { + return false; + } + } + + /// Aplica a limpeza do pedido [orderId]. Chamadas concorrentes compartilham + /// o mesmo `Future`. O id lido aqui, ANTES de apagar, é o que vai na + /// confirmação (o apagador o troca). Falha do apagador sobe com o diário em + /// `erasing`. + Future apply(String orderId) => _single(() async { + final entry = await _readJournal(); + if (entry != null && entry.orderId == orderId) { + // Mesmo pedido já no diário (outra partida começou ou confirmou + // pela metade): continua de onde parou, com o id do diário. + await _run(orderId, entry.deviceId, entry.phase); + return; + } + if (_erasedOrders.contains(orderId)) return; + final deviceId = await _deviceIds.readOrCreate(); + await _run(orderId, deviceId, WipePhase.erasing); + }); + + Future _single(Future Function() body) { + final pending = _inFlight; + if (pending != null) return pending; + late final Future flight; + flight = body().whenComplete(() { + if (identical(_inFlight, flight)) _inFlight = null; + }); + return _inFlight = flight; + } + + Future _run(String orderId, String deviceId, WipePhase phase) async { + if (phase == WipePhase.erasing) { + // D7: o diário vem ANTES de qualquer exclusão. Se ele não grava, nada + // é apagado e o erro sobe: o próximo gatilho tenta de novo. + await _journal.write(orderId, deviceId, WipePhase.erasing); + await _erase(); + _erasedOrders.add(orderId); + try { + await _journal.write(orderId, deviceId, WipePhase.erased); + } catch (_) { + // O aparelho já está limpo. Com o diário ainda em `erasing`, a + // próxima partida só refaz uma limpeza idempotente. + } + _onWiped?.call(); + } else { + _erasedOrders.add(orderId); + } + await _confirm(orderId, deviceId); + } + + /// Confirmação no servidor. Sem rede, o diário fica em `erased` para o + /// próximo gatilho; só a resposta do servidor apaga o diário. + Future _confirm(String orderId, String deviceId) async { + try { + await _backend.confirmDeviceWipe(deviceId, orderId); + } catch (_) { + return; + } + try { + await _journal.clear(); + } catch (_) { + // Reenviar uma confirmação é inofensivo: o servidor a trata como no-op. + } + } + + Future _readJournal() async { + try { + return await _journal.read(); + } catch (_) { + return null; + } + } +} diff --git a/apps/acs/test/backend_client_wipe_test.dart b/apps/acs/test/backend_client_wipe_test.dart new file mode 100644 index 0000000..35c05d0 --- /dev/null +++ b/apps/acs/test/backend_client_wipe_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; + +import 'support/fake_rpc_server.dart'; + +/// Limpeza remota do aparelho (#81) vista pelo `BackendClient`: o servidor +/// recusa login, renovação e envio diferido com `DeviceWipeRequiredException`, +/// e o cliente avisa por `onDeviceWipeRequired` — nunca por +/// `onSessionExpired`. Servidor local e credenciais sintéticas. +void main() { + late FakeRpcServer server; + late MemorySessionTokenStore store; + late BackendClient backend; + late List pedidos; + late int expiradas; + + const orderId = '00000000-0000-4000-8000-0000000000aa'; + const mensagem = 'Este aparelho precisa ser limpo a pedido da coordenação.'; + + setUp(() async { + server = await FakeRpcServer.start(); + store = MemorySessionTokenStore(); + backend = BackendClient(host: server.host, tokenStore: store, deviceIds: MemoryDeviceIdStore('aparelho-antigo')); + pedidos = []; + expiradas = 0; + backend.onDeviceWipeRequired = pedidos.add; + backend.onSessionExpired = () => expiradas++; + }); + + tearDown(() async { + backend.close(); + await server.stop(); + }); + + Matcher falhaDeLimpeza() => throwsA(isA() + .having((f) => f.message, 'message', mensagem) + .having((f) => f.isRecoverable, 'isRecoverable', isFalse)); + + test('renewSession com DeviceWipeRequiredException chama onDeviceWipeRequired e não onSessionExpired', () async { + server.tokenLifetime = const Duration(minutes: -1); + await backend.login(matricula: 'ACS-001', senha: 'senha-sintetica'); + server.wipeOrderId = orderId; + + await expectLater(backend.renewSession(), falhaDeLimpeza()); + + expect(pedidos, [orderId]); + expect(expiradas, 0); + }); + + test('login com DeviceWipeRequiredException chama onDeviceWipeRequired e lança BackendFailure não recuperável', () async { + server.wipeOrderId = orderId; + + await expectLater(backend.login(matricula: 'ACS-001', senha: 'senha-sintetica'), falhaDeLimpeza()); + + expect(pedidos, [orderId]); + expect(expiradas, 0); + expect(backend.session, isNull); + expect(await store.read(), isNull); + }); + + test('syncDeferred com DeviceWipeRequiredException chama onDeviceWipeRequired', () async { + server.wipeOrderId = orderId; + + await expectLater( + backend.syncDeferredVisits(uploadToken: 'upload-0', deviceId: 'aparelho-antigo', visits: const []), + falhaDeLimpeza(), + ); + + expect(pedidos, [orderId]); + expect(expiradas, 0); + }); + + test('forgetLocalSession apaga o token e a sessão sem rede', () async { + await backend.login(matricula: 'ACS-001', senha: 'senha-sintetica'); + expect(await store.read(), isNotNull); + final antes = server.requests.length; + + await backend.forgetLocalSession(); + + expect(backend.session, isNull); + expect(backend.isAuthenticated, isFalse); + expect(await store.read(), isNull); + expect(server.requests.length, antes, reason: 'nenhuma chamada de rede'); + expect(expiradas, 0); + }); + + test('forgetLocalSession torna obsoleta a renovação em voo', () async { + server.tokenLifetime = const Duration(minutes: -1); + await backend.login(matricula: 'ACS-001', senha: 'senha-sintetica'); + server.refreshDelay = const Duration(milliseconds: 200); + + final voo = backend.renewSession(); + await Future.delayed(const Duration(milliseconds: 50)); + await backend.forgetLocalSession(); + + await expectLater(voo, throwsA(isA())); + expect(backend.session, isNull); + expect(await store.read(), isNull, reason: 'o filho da renovação obsoleta não é gravado'); + }); + + test('pendingDeviceWipe e confirmDeviceWipe falam com devices.*', () async { + expect(await backend.pendingDeviceWipe('aparelho-antigo'), isNull); + await backend.confirmDeviceWipe('aparelho-antigo', orderId); + + final chamadas = server.requests.where((r) => r.endpoint == 'devices').toList(); + expect(chamadas.map((r) => r.method), ['pendingWipe', 'confirmWipe']); + expect(chamadas.first.args['deviceId'], 'aparelho-antigo'); + expect(chamadas.last.args['orderId'], orderId); + }); + + test('pendingDeviceWipe devolve o orderId do servidor', () async { + server.pendingWipeOrderId = orderId; + expect(await backend.pendingDeviceWipe('aparelho-antigo'), orderId); + }); + + test('pendingDeviceWipe sem rede vira BackendFailure recuperável', () async { + await server.stop(); + await expectLater( + backend.pendingDeviceWipe('aparelho-antigo'), + throwsA(isA().having((f) => f.isRecoverable, 'isRecoverable', isTrue)), + ); + }); +} diff --git a/apps/acs/test/refresh_session_test.dart b/apps/acs/test/refresh_session_test.dart index ec92dd6..cc9be1a 100644 --- a/apps/acs/test/refresh_session_test.dart +++ b/apps/acs/test/refresh_session_test.dart @@ -518,6 +518,9 @@ class ThrowingStore implements SessionTokenStore { class ThrowingDevices implements DeviceIdStore { @override Future readOrCreate() async => throw PlatformException(code: 'x'); + + @override + Future delete() async => throw PlatformException(code: 'x'); } class SlowStorage implements FlutterSecureStorage { diff --git a/apps/acs/test/remote_wipe_test.dart b/apps/acs/test/remote_wipe_test.dart new file mode 100644 index 0000000..0bbe369 --- /dev/null +++ b/apps/acs/test/remote_wipe_test.dart @@ -0,0 +1,306 @@ +import 'dart:async'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/secure_session_token_store.dart'; +import 'package:sinalacs_acs/core/services/remote_wipe.dart'; + +import 'support/fakes.dart'; + +/// Limpeza remota do aparelho (#81): o coordenador é single-flight, grava o +/// diário ANTES de apagar (D7) e só o apaga depois de o servidor confirmar. +/// Ids sintéticos. +void main() { + const orderId = '00000000-0000-4000-8000-0000000000aa'; + const idAntigo = '00000000-0000-4000-8000-0000000000d1'; + + late FakeAcsBackend backend; + late MemoryDeviceIdStore deviceIds; + late MemoryWipeJournal journal; + late List eventos; + late int eraseCalls; + late int wipedCalls; + Object? falhaDoErase; + Completer? eraseGate; + + Future erase() async { + eraseCalls++; + eventos.add('erase'); + await eraseGate?.future; + final falha = falhaDoErase; + if (falha != null) throw falha; + // O apagador real (Task 5) troca o id da instalação. + await deviceIds.delete(); + } + + RemoteWipeCoordinator novo() => RemoteWipeCoordinator( + backend: backend, + deviceIds: deviceIds, + journal: journal, + erase: erase, + onWiped: () { + wipedCalls++; + eventos.add('onWiped'); + }, + ); + + setUp(() { + backend = FakeAcsBackend(); + deviceIds = MemoryDeviceIdStore(idAntigo); + journal = MemoryWipeJournal(); + eventos = []; + eraseCalls = 0; + wipedCalls = 0; + falhaDoErase = null; + eraseGate = null; + backend.onConfirmDeviceWipe = () => eventos.add('confirm'); + }); + + test('check sem pedido não apaga nada', () async { + final coordinator = novo(); + + expect(await coordinator.check(), isFalse); + + expect(backend.pendingWipeQueries, [idAntigo]); + expect(eraseCalls, 0); + expect(wipedCalls, 0); + expect(backend.confirmedWipes, isEmpty); + expect(await journal.read(), isNull); + }); + + test('check sem rede devolve false e não lança', () async { + backend.pendingWipeFailure = const BackendFailure('Sem conexão com o servidor.'); + + expect(await novo().check(), isFalse); + expect(eraseCalls, 0); + }); + + test('check com pedido apaga, chama onWiped e confirma com o deviceId antigo', () async { + backend.pendingWipeOrderId = orderId; + + expect(await novo().check(), isTrue); + + expect(eraseCalls, 1); + expect(wipedCalls, 1); + expect(eventos, ['erase', 'onWiped', 'confirm']); + expect(backend.confirmedWipes, [(deviceId: idAntigo, orderId: orderId)]); + expect(await deviceIds.readOrCreate(), isNot(idAntigo), reason: 'o apagador trocou o id'); + expect(await journal.read(), isNull); + }); + + test('o diário é gravado em erasing ANTES de apagar', () async { + eraseGate = Completer(); + final apply = novo().apply(orderId); + await pumpEventQueue(); + + expect(eraseCalls, 1); + expect(await journal.read(), (orderId: orderId, deviceId: idAntigo, phase: WipePhase.erasing)); + + eraseGate!.complete(); + await apply; + expect(await journal.read(), isNull); + }); + + test('gatilhos concorrentes compartilham uma só limpeza', () async { + backend.pendingWipeOrderId = orderId; + eraseGate = Completer(); + final coordinator = novo(); + + final todos = Future.wait([coordinator.check(), coordinator.apply(orderId), coordinator.apply(orderId)]); + await pumpEventQueue(); + eraseGate!.complete(); + await todos; + // Um gatilho atrasado depois da limpeza também não refaz nada. + await coordinator.apply(orderId); + + expect(eraseCalls, 1); + expect(backend.confirmedWipes, hasLength(1)); + expect(wipedCalls, 1); + }); + + test('gatilho que chega depois da limpeza não apaga de novo', () async { + backend.pendingWipeOrderId = orderId; + backend.pendingWipeGate = Completer(); + final coordinator = novo(); + + // A consulta sai com o id antigo, mas a resposta chega depois de outro + // gatilho já ter limpado e confirmado. + final consulta = coordinator.check(); + await pumpEventQueue(); + await coordinator.apply(orderId); + backend.pendingWipeGate!.complete(); + await consulta; + + expect(eraseCalls, 1); + expect(backend.confirmedWipes, hasLength(1)); + }); + + test('confirmação sem rede fica no diário e é reenviada', () async { + backend.confirmWipeFailure = const BackendFailure('Sem conexão com o servidor.'); + + await novo().apply(orderId); + + expect(eraseCalls, 1); + expect(wipedCalls, 1); + expect(await journal.read(), (orderId: orderId, deviceId: idAntigo, phase: WipePhase.erased)); + + // Próxima partida: só reenvia a confirmação, com o id antigo do diário. + backend.confirmWipeFailure = null; + await novo().resumePending(); + + expect(eraseCalls, 1, reason: 'erased não apaga de novo'); + // A 1ª confirmação falhou (não chegou); a reenviada leva o id antigo. + expect(eventos.where((e) => e == 'confirm'), hasLength(2)); + expect(backend.confirmedWipes, [(deviceId: idAntigo, orderId: orderId)]); + expect(await journal.read(), isNull); + }); + + test('diário em erasing termina a limpeza na partida antes de abrir o banco', () async { + // O processo morreu no meio da limpeza: o id da instalação já pode ser + // outro, mas a confirmação vai com o do diário. + await journal.write(orderId, idAntigo, WipePhase.erasing); + deviceIds = MemoryDeviceIdStore('00000000-0000-4000-8000-0000000000d2'); + + await novo().resumePending(); + + expect(eventos, ['erase', 'onWiped', 'confirm']); + expect(backend.confirmedWipes, [(deviceId: idAntigo, orderId: orderId)]); + expect(await journal.read(), isNull); + }); + + test('resumePending sem diário não faz nada', () async { + await novo().resumePending(); + + expect(eraseCalls, 0); + expect(backend.pendingWipeQueries, isEmpty); + expect(backend.confirmedWipes, isEmpty); + }); + + test('falha do erase mantém erasing e não confirma', () async { + falhaDoErase = StateError('disco ocupado'); + final coordinator = novo(); + + await expectLater(coordinator.apply(orderId), throwsA(isA())); + + expect(await journal.read(), (orderId: orderId, deviceId: idAntigo, phase: WipePhase.erasing)); + expect(backend.confirmedWipes, isEmpty); + expect(wipedCalls, 0); + + // A próxima partida tenta de novo. + falhaDoErase = null; + await novo().resumePending(); + + expect(eraseCalls, 2); + expect(backend.confirmedWipes, [(deviceId: idAntigo, orderId: orderId)]); + expect(await journal.read(), isNull); + }); + + test('check com falha do erase devolve false e não lança', () async { + backend.pendingWipeOrderId = orderId; + falhaDoErase = StateError('disco ocupado'); + + expect(await novo().check(), isFalse); + expect(backend.confirmedWipes, isEmpty); + }); + + test('o pedido vindo do BackendClient dispara apply', () async { + final coordinator = novo(); + backend.onDeviceWipeRequired = (id) => unawaited(coordinator.apply(id)); + + backend.onDeviceWipeRequired!(orderId); + await pumpEventQueue(); + + expect(eraseCalls, 1); + expect(backend.confirmedWipes, [(deviceId: idAntigo, orderId: orderId)]); + }); + + group('SharedPreferencesWipeJournal', () { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('grava, lê e apaga o diário em acs_remote_wipe_journal', () async { + final diario = SharedPreferencesWipeJournal(); + expect(await diario.read(), isNull); + + await diario.write(orderId, idAntigo, WipePhase.erased); + + expect(await diario.read(), (orderId: orderId, deviceId: idAntigo, phase: WipePhase.erased)); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('acs_remote_wipe_journal'), contains('"phase":"erased"')); + + await diario.clear(); + expect(await diario.read(), isNull); + expect(prefs.containsKey('acs_remote_wipe_journal'), isFalse); + }); + + test('diário ilegível é descartado como ausente', () async { + SharedPreferences.setMockInitialValues({'acs_remote_wipe_journal': 'lixo'}); + expect(await SharedPreferencesWipeJournal().read(), isNull); + }); + }); + + test('SecureStorageDeviceIdStore.delete apaga acs_device_id e o próximo id é outro', () async { + final storage = _MapStorage(); + final ids = SecureStorageDeviceIdStore(storage: storage); + final primeiro = await ids.readOrCreate(); + expect(storage.data['acs_device_id'], primeiro); + + await ids.delete(); + expect(storage.data.containsKey('acs_device_id'), isFalse); + + final segundo = await ids.readOrCreate(); + expect(segundo, isNot(primeiro)); + expect(storage.data['acs_device_id'], segundo); + }); + + test('MemoryDeviceIdStore.delete troca o id', () async { + final ids = MemoryDeviceIdStore(idAntigo); + await ids.delete(); + expect(await ids.readOrCreate(), isNot(idAntigo)); + }); +} + +class _MapStorage implements FlutterSecureStorage { + final Map data = {}; + + @override + Future read({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => + data[key]; + + @override + Future write({ + required String key, + required String? value, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => + data[key] = value!; + + @override + Future delete({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => + data.remove(key); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/apps/acs/test/support/fake_rpc_server.dart b/apps/acs/test/support/fake_rpc_server.dart index ce3de8c..231bed3 100644 --- a/apps/acs/test/support/fake_rpc_server.dart +++ b/apps/acs/test/support/fake_rpc_server.dart @@ -125,6 +125,14 @@ class FakeRpcServer { /// Faz `refreshSession` devolver um refresh token vazio. bool emptyRefreshToken = false; + /// Pedido de limpeza remota (#81): definido, `loginInstitutional`, + /// `refreshSession` e `syncDeferred` recusam com + /// `DeviceWipeRequiredException` carregando este `orderId`. + String? wipeOrderId; + + /// O que `devices.pendingWipe` devolve (`null`: nenhum pedido). + String? pendingWipeOrderId; + /// Tokens que `logout` recebeu. final List loggedOut = []; @@ -162,6 +170,15 @@ class FakeRpcServer { args: decoded, )); + if (wipeOrderId != null && + (method == 'loginInstitutional' || method == 'refreshSession' || method == 'syncDeferred')) { + await _respond(request, HttpStatus.badRequest, { + 'className': 'DeviceWipeRequiredException', + 'data': {'orderId': wipeOrderId, 'message': 'Este aparelho precisa ser limpo.'}, + }); + return; + } + if (method == 'loginInstitutional' && rejectWith != null) { await _respond(request, HttpStatus.badRequest, { 'className': 'AuthenticationFailedException', @@ -211,6 +228,20 @@ class FakeRpcServer { return; } + if (method == 'pendingWipe') { + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write(jsonEncode(pendingWipeOrderId)); + await request.response.close(); + return; + } + + if (method == 'confirmWipe') { + await _respond(request, HttpStatus.ok, null); + return; + } + if (method == 'revokeUploadToken') { revokedUploadTokens.add(decoded['uploadToken'] as String? ?? ''); await _respond(request, HttpStatus.ok, null); diff --git a/apps/acs/test/support/fakes.dart b/apps/acs/test/support/fakes.dart index 0460266..8917ac5 100644 --- a/apps/acs/test/support/fakes.dart +++ b/apps/acs/test/support/fakes.dart @@ -480,6 +480,56 @@ class FakeAcsBackend implements AcsBackend { return noticeResult; } + @override + void Function(String orderId)? onDeviceWipeRequired; + + /// O que `pendingDeviceWipe` devolve (`null`: nenhum pedido de limpeza). + String? pendingWipeOrderId; + + /// Falha de `pendingDeviceWipe`, como a queda de rede. + BackendFailure? pendingWipeFailure; + + /// Se definido, `pendingDeviceWipe` espera por ele (consulta em voo). + Completer? pendingWipeGate; + + /// `deviceId` de cada consulta, na ordem. + final List pendingWipeQueries = []; + + @override + Future pendingDeviceWipe(String deviceId) async { + pendingWipeQueries.add(deviceId); + await pendingWipeGate?.future; + final failure = pendingWipeFailure; + if (failure != null) throw failure; + return pendingWipeOrderId; + } + + /// Falha de `confirmDeviceWipe` (sem rede). + BackendFailure? confirmWipeFailure; + + /// Chamado a cada `confirmDeviceWipe`, antes da falha configurada. + void Function()? onConfirmDeviceWipe; + + /// Confirmações que chegaram ao "servidor" (só as que não falharam). + final List<({String deviceId, String orderId})> confirmedWipes = <({String deviceId, String orderId})>[]; + + @override + Future confirmDeviceWipe(String deviceId, String orderId) async { + onConfirmDeviceWipe?.call(); + final failure = confirmWipeFailure; + if (failure != null) throw failure; + confirmedWipes.add((deviceId: deviceId, orderId: orderId)); + } + + int forgetLocalSessionCount = 0; + + @override + Future forgetLocalSession() async { + forgetLocalSessionCount++; + storedRefreshToken = null; + _session = null; + } + /// Entradas que `pullVisits` devolve. Vazio por padrão. List pullEntries = const []; From 791f698ed1614bde3aa7335dbda5f291297465ca Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 15:31:02 -0400 Subject: [PATCH 08/17] =?UTF-8?q?feat(acs):=20app=20aplica=20a=20limpeza?= =?UTF-8?q?=20remota=20na=20partida,=20na=20retomada,=20na=20volta=20da=20?= =?UTF-8?q?rede=20e=20na=20renova=C3=A7=C3=A3o=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/acs/lib/app/app.dart | 309 +++++++++++++++++-- apps/acs/lib/app/device_wiped_screen.dart | 79 +++++ apps/acs/lib/main.dart | 4 + apps/acs/test/main_boot_ca_test.dart | 16 +- apps/acs/test/remote_wipe_ui_test.dart | 287 +++++++++++++++++ apps/acs/test/session_store_wiring_test.dart | 5 + apps/acs/test/support/fakes.dart | 9 + 7 files changed, 688 insertions(+), 21 deletions(-) create mode 100644 apps/acs/lib/app/device_wiped_screen.dart create mode 100644 apps/acs/test/remote_wipe_ui_test.dart diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index 0ba7aa1..347d808 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -7,6 +7,7 @@ import 'package:geolocator/geolocator.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:sinalacs_acs/app/acs_theme.dart'; import 'package:sinalacs_acs/app/app_lock_gate.dart'; +import 'package:sinalacs_acs/app/device_wiped_screen.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'; @@ -29,6 +30,7 @@ import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; import 'package:sinalacs_acs/core/services/reconnect_schedule.dart'; +import 'package:sinalacs_acs/core/services/remote_wipe.dart'; import 'package:sinalacs_acs/core/services/route_service.dart'; import 'package:sinalacs_acs/core/services/theme_controller.dart'; import 'package:sinalacs_acs/core/services/visit_pull_service.dart'; @@ -56,6 +58,7 @@ class SinalAcsApp extends StatefulWidget { this.uploadTokens, this.deviceIds, this.connectivityChanges, + this.wipeJournal, }); /// Envio diferido (D4/D7) já montado — gancho de teste. `null` monta um @@ -76,6 +79,11 @@ class SinalAcsApp extends StatefulWidget { /// sem esse gatilho (o canal de plataforma não existe no `flutter test`). final Stream? connectivityChanges; + /// Diário da limpeza remota (#81, D7). Em produção, o `main.dart` passa o + /// `SharedPreferencesWipeJournal`; `null` usa um diário só em memória. A + /// limpeza remota só existe com [deviceIds] (o id que o servidor conhece). + final WipeJournal? wipeJournal; + /// Desbloqueio local (digital/rosto ou bloqueio de tela). `null` usa /// [LocalAuthBiometricGate]; testes injetam um duplo. final BiometricGate? biometricGate; @@ -146,6 +154,17 @@ const sessionEndSendTimeout = Duration(seconds: 20); /// (best-effort: o token apagado do aparelho já não serve a ninguém aqui). const wipeRevokeTimeout = Duration(seconds: 5); +/// Teto da consulta de limpeza remota na partida (#81, D3): o login espera por +/// ela para não abrir a sessão de um aparelho já mandado limpar, mas sem rede +/// que responda ele segue — a consulta continua e, se trouxer pedido, a +/// limpeza acontece por cima do login. +const startupWipeCheckTimeout = Duration(seconds: 5); + +/// Teto da espera por um envio da fila do painel já em voo antes da limpeza +/// remota: o fim dele grava no disco, e gravar DEPOIS da destruição recriaria +/// o banco com as visitas que a coordenação mandou apagar. +const remoteWipeSyncWaitTimeout = Duration(seconds: 5); + /// O que o painel pede ao app ao encerrar o turno ou limpar o aparelho: o app /// é dono do armazenamento, das filas memoizadas e do envio diferido. abstract interface class _DeviceCare { @@ -494,10 +513,238 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i /// nada (nem conteúdo clínico, nem erro) — `flushAll` não lança, é /// single-flight, e o que não subir fica para o próximo gatilho. void _triggerFlush() { + // Antes do diário da partida ou durante a limpeza remota, nada sai: o + // banco pode estar sendo apagado (#81). + if (!_wipeReady || _wiping) return; final flush = _flush; if (flush != null) unawaited(flush.flushAll()); } + // --------------------------------------------------------------------------- + // Limpeza remota pedida pela coordenação (#81). + // + // Ordem na partida: `resumePending()` (diário em `erasing` ou `erased`) ANTES + // de qualquer uso do banco e de ligar os outros gatilhos — o single-flight do + // coordenador ignora o `orderId`, e uma retomada correndo com um `apply` + // poderia perder um pedido. Depois, `check()` com teto, e só então o login. + // Retomada e volta da rede repetem `resumePending()` (reenvia a confirmação + // que ficou no diário, D7) e `check()`. INV-03: nada aqui reconhece nem + // conclui alerta; eles seguem `pending` no servidor. + // --------------------------------------------------------------------------- + + late final WipeJournal _wipeJournal = widget.wipeJournal ?? MemoryWipeJournal(); + + /// Sem [SinalAcsApp.deviceIds] não há id da instalação para perguntar ao + /// servidor: sem limpeza remota (o padrão dos testes de widget). + late final RemoteWipeCoordinator? _remoteWipe = widget.deviceIds == null + ? null + : RemoteWipeCoordinator( + backend: widget.backend, + deviceIds: widget.deviceIds!, + journal: _wipeJournal, + erase: _eraseDevice, + onWiped: _onDeviceWiped, + ); + + /// `false` até o diário e a consulta da partida terminarem: até lá a raiz + /// é uma espera neutra, e nem o login nem o envio diferido tocam no banco. + final ValueNotifier _wipeReadyNotifier = ValueNotifier(false); + bool get _wipeReady => _wipeReadyNotifier.value; + + /// `true` do começo do apagador até `onWiped` (ou até a falha dele). + bool _wiping = false; + + /// `true` com a tela `device_wiped` à vista: o [AppLockGate] sai de cima + /// dela (não há o que proteger, e não há sessão para desbloquear). + bool _showingWiped = false; + + /// Diário em `erased`: o aparelho está limpo, mas a confirmação não chegou. + final ValueNotifier _wipePendingConfirm = ValueNotifier(false); + + Future _startRemoteWipe() async { + final wipe = _remoteWipe!; + try { + await wipe.resumePending(); + } catch (_) { + // O apagador falhou: o diário segue em `erasing` e a próxima retomada, + // volta da rede ou partida tenta de novo. Nada do erro vai ao log. + developer.log('Limpeza remota pendente não terminou.', name: 'sinalacs.acs.remote_wipe'); + } + if (!mounted) return; + widget.backend.onDeviceWipeRequired = _onDeviceWipeRequired; + await wipe.check().timeout(startupWipeCheckTimeout, onTimeout: () => false); + await _refreshWipePendingConfirm(); + if (!mounted) return; + _wipeReadyNotifier.value = true; + } + + /// Login, renovação ou envio diferido recusados com pedido de limpeza. + void _onDeviceWipeRequired(String orderId) { + final wipe = _remoteWipe; + if (wipe == null || !mounted) return; + unawaited(() async { + try { + await wipe.apply(orderId); + } catch (_) { + developer.log('Limpeza remota não terminou.', name: 'sinalacs.acs.remote_wipe'); + } + await _refreshWipePendingConfirm(); + }()); + } + + /// Retomada e volta da rede: reenvia a confirmação pendente e pergunta se + /// há pedido. `true` quando o aparelho foi limpo agora. + Future _checkRemoteWipe() async { + final wipe = _remoteWipe; + if (wipe == null || !_wipeReady) return false; + try { + await wipe.resumePending(); + } catch (_) { + developer.log('Limpeza remota pendente não terminou.', name: 'sinalacs.acs.remote_wipe'); + } + final wiped = await wipe.check(); + await _refreshWipePendingConfirm(); + return wiped; + } + + /// Gatilho da retomada e da volta da rede: primeiro a limpeza, e o envio + /// diferido só se o aparelho não foi limpo. + void _onForegroundOrOnline() { + if (_remoteWipe == null) { + _triggerFlush(); + return; + } + unawaited(() async { + final wiped = await _checkRemoteWipe(); + if (!wiped && mounted) _triggerFlush(); + }()); + } + + Future _refreshWipePendingConfirm() async { + WipeJournalEntry? entry; + try { + entry = await _wipeJournal.read(); + } catch (_) { + entry = null; + } + if (!mounted) return; + _wipePendingConfirm.value = entry?.phase == WipePhase.erased; + } + + /// O apagador ([DeviceEraser]) do coordenador. Idempotente. Antes de apagar, + /// desmonta o painel (feed MQTT, reserva por HTTP, timers e formulários saem + /// com ele) e espera o envio da fila que já estava em voo, com teto; depois + /// descarta as filas em memória SEM gravar e apaga, nesta ordem: a sessão + /// local, o banco e a chave do SQLCipher, os tokens de envio (sem revogar: o + /// servidor já os revogou no pedido) e o id da instalação. D2: ao contrário + /// do "Limpar este aparelho", nada aqui recusa por visita não enviada. + Future _eraseDevice() async { + _wiping = true; + try { + await _tearDownForWipe(); + final emVoo = {..._queues.values, for (final scope in _scopes.values) scope.queue}; + final limite = DateTime.now().add(remoteWipeSyncWaitTimeout); + while (emVoo.any((queue) => queue.isSyncing) && DateTime.now().isBefore(limite)) { + await Future.delayed(const Duration(milliseconds: 50)); + } + final owners = {..._queues.keys}; + _queues.clear(); + _scopes.clear(); + await widget.backend.forgetLocalSession(); + await _storage.destroyAll(); + final tokens = widget.uploadTokens; + if (tokens != null) { + try { + owners.addAll(await tokens.owners()); + } catch (_) {} + try { + await tokens.clearAll(alsoOwners: owners); + } catch (_) { + // Keystore falhou: sem banco, um token que sobrar não acha visita + // nenhuma, e o servidor já o revogou. + } + } + await widget.deviceIds!.delete(); + } catch (_) { + _wiping = false; + if (mounted) _openFreshLogin(); + rethrow; + } + } + + /// Tira o painel (e tudo o mais) da pilha e espera os quadros em que os + /// `State`s saem: nenhum `setState` do painel roda depois disto. + Future _tearDownForWipe() async { + var nav = _navigatorKey.currentState; + if (nav == null) { + await WidgetsBinding.instance.endOfFrame; + nav = _navigatorKey.currentState; + } + if (nav == null || !mounted) return; + nav.pushAndRemoveUntil( + PageRouteBuilder( + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, __, ___) => const _WipeHold(), + ), + (_) => false, + ); + await WidgetsBinding.instance.endOfFrame; + await WidgetsBinding.instance.endOfFrame; + } + + /// `onWiped`: a pilha inteira vira a tela `device_wiped`, fora do bloqueio. + void _onDeviceWiped() { + _wiping = false; + if (!mounted) return; + setState(() => _showingWiped = true); + void show() => _navigatorKey.currentState?.pushAndRemoveUntil( + PageRouteBuilder( + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, __, ___) => DeviceWipedScreen( + pendingConfirm: _wipePendingConfirm, + onLoginAgain: _loginAgainAfterWipe, + ), + ), + (_) => false, + ); + if (_navigatorKey.currentState != null) { + show(); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) show(); + }); + } + } + + /// "Entrar de novo": login novo, com o bloqueio de volta. O id da instalação + /// já foi trocado pelo apagador; o próximo login gera e envia o novo. + void _loginAgainAfterWipe() { + if (!mounted) return; + setState(() => _showingWiped = false); + _openFreshLogin(); + } + + void _openFreshLogin() { + _navigatorKey.currentState?.pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => _loginScreen()), + (_) => false, + ); + } + + LoginScreen _loginScreen() => LoginScreen( + feedBuilder: widget.feedBuilder, + visitScopeFor: _scopeForShell, + directory: widget.microAreaDirectory, + initialAlert: widget.initialAlert, + initialPosition: widget.currentPosition, + themeController: _themeController, + syncInterval: widget.syncInterval, + alertFallbackInterval: widget.alertFallbackInterval, + biometricGate: _gate, + ); + /// Resolve o escopo do painel que vai abrir e dispara o envio diferido /// (gatilho "depois de abrir o painel"). Só `_openShell` chama o resolvedor. OwnerVisitScope _scopeForShell(AuthSession session) { @@ -514,19 +761,29 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i _connectivity = widget.connectivityChanges?.listen((online) { final was = _online; _online = online; - if (online && was != true) _triggerFlush(); + if (online && was != true) _onForegroundOrOnline(); }); + if (_remoteWipe == null) { + _wipeReadyNotifier.value = true; + } else { + unawaited(_startRemoteWipe()); + } } @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) _triggerFlush(); + if (state == AppLifecycleState.resumed) _onForegroundOrOnline(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _connectivity?.cancel(); + if (widget.backend.onDeviceWipeRequired == _onDeviceWipeRequired) { + widget.backend.onDeviceWipeRequired = null; + } + _wipeReadyNotifier.dispose(); + _wipePendingConfirm.dispose(); if (widget.themeController == null) _themeController.dispose(); super.dispose(); } @@ -593,24 +850,25 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i themeMode: mode, // No `builder`, ACIMA do Navigator: cobre também as rotas // empilhadas (formulário de visita, reautenticação). - builder: (context, child) => AppLockGate( - key: ValueKey(_gateGeneration), - gate: _gate, - navigatorKey: _navigatorKey, - lockAfter: widget.lockAfter ?? const Duration(seconds: 30), - onUsePassword: _usePassword, - child: child!, - ), - home: LoginScreen( - feedBuilder: widget.feedBuilder, - visitScopeFor: _scopeForShell, - directory: widget.microAreaDirectory, - initialAlert: widget.initialAlert, - initialPosition: widget.currentPosition, - themeController: _themeController, - syncInterval: widget.syncInterval, - alertFallbackInterval: widget.alertFallbackInterval, - biometricGate: _gate, + // Com a tela `device_wiped` à vista, o gate sai (#81): o + // Navigator sobrevive à troca de pai pela `GlobalKey`, como na + // recriação do gate por "Entrar com senha". + builder: (context, child) => _showingWiped + ? child! + : AppLockGate( + key: ValueKey(_gateGeneration), + gate: _gate, + navigatorKey: _navigatorKey, + lockAfter: widget.lockAfter ?? const Duration(seconds: 30), + onUsePassword: _usePassword, + child: child!, + ), + // Até o diário da limpeza remota e a consulta da partida + // terminarem, uma espera neutra: o login (e a retomada da + // sessão) nunca abre antes de o aparelho saber se foi limpo. + home: ValueListenableBuilder( + valueListenable: _wipeReadyNotifier, + builder: (context, ready, _) => ready ? _loginScreen() : const _WipeHold(), ), ), ), @@ -619,6 +877,17 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i ); } +/// Espera neutra, sem texto nem dado: a partida antes do diário da limpeza +/// remota e o intervalo em que o aparelho está sendo apagado (#81). +class _WipeHold extends StatelessWidget { + const _WipeHold(); + + @override + Widget build(BuildContext context) => const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); +} + /// Ponte entre o app (dono do [AppLockGate]) e o painel aberto (dono das /// rotas e do estado que a reautenticação precisa preservar). class _AppLockHooks { diff --git a/apps/acs/lib/app/device_wiped_screen.dart b/apps/acs/lib/app/device_wiped_screen.dart new file mode 100644 index 0000000..2e8a39f --- /dev/null +++ b/apps/acs/lib/app/device_wiped_screen.dart @@ -0,0 +1,79 @@ +import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/material.dart'; + +/// Tela final da limpeza remota pedida pela coordenação (#81). +/// +/// Aparece no lugar de tudo (a pilha inteira sai), e o [AppLockGate] não vale +/// para ela: não há dado nenhum para proteger, e o bloqueio por digital pediria +/// para "desbloquear" um aparelho que já não tem sessão. Quem tira o gate é o +/// `SinalAcsApp` enquanto esta tela está à vista. +/// +/// INV-03: os alertas não foram descartados; continuam `pending` no servidor, +/// com a equipe da UBS. +class DeviceWipedScreen extends StatelessWidget { + const DeviceWipedScreen({super.key, required this.pendingConfirm, required this.onLoginAgain}); + + /// `true` enquanto o diário está em `erased`: o aparelho está limpo, mas o + /// servidor ainda não recebeu a confirmação. + final ValueListenable pendingConfirm; + + /// "Entrar de novo": volta a um login novo (com outro id da instalação). + final VoidCallback onLoginAgain; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return PopScope( + canPop: false, + child: Scaffold( + body: SafeArea( + child: Semantics( + key: const Key('device_wiped'), + liveRegion: true, + container: true, + // Coluna rolável, e não `ListView`: com fonte grande a linha da + // confirmação pendente ficaria fora da área construída. + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon(Icons.phonelink_erase, size: 48, color: theme.colorScheme.onSurface), + const SizedBox(height: 16), + Text('Aparelho limpo pela coordenação', style: theme.textTheme.headlineSmall), + const SizedBox(height: 12), + const Text( + 'Os dados do SinalACS deste aparelho foram apagados a pedido da coordenação da UBS, ' + 'inclusive as visitas que ainda não tinham sido enviadas. Os alertas continuam com a equipe da UBS.', + ), + ValueListenableBuilder( + valueListenable: pendingConfirm, + builder: (context, pending, _) => pending + ? Padding( + padding: const EdgeInsets.only(top: 12), + child: Semantics( + liveRegion: true, + child: const Text( + 'A confirmação ainda não chegou ao servidor; será enviada quando houver conexão.', + key: Key('device_wiped_pending_confirm'), + ), + ), + ) + : const SizedBox.shrink(), + ), + const SizedBox(height: 24), + FilledButton( + key: const Key('device_wiped_login'), + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: onLoginAgain, + child: const Text('Entrar de novo'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/acs/lib/main.dart b/apps/acs/lib/main.dart index 67a8cd4..a5bc07e 100644 --- a/apps/acs/lib/main.dart +++ b/apps/acs/lib/main.dart @@ -12,6 +12,7 @@ import 'package:sinalacs_acs/core/security/keystore_vault.dart'; import 'package:sinalacs_acs/core/security/secure_session_token_store.dart'; import 'package:sinalacs_acs/core/security/secure_upload_token_store.dart'; import 'package:sinalacs_acs/core/services/micro_area_directory_factory.dart'; +import 'package:sinalacs_acs/core/services/remote_wipe.dart'; /// Sobe o app do ACS. /// @@ -82,6 +83,9 @@ Future main({String? defaultHost}) async { connectivityChanges: Connectivity() .onConnectivityChanged .map((results) => results.any((r) => r != ConnectivityResult.none)), + // Diário da limpeza remota (#81, D7) em `shared_preferences`: fora do + // banco e do Keystore, que são justamente o que a limpeza apaga. + wipeJournal: SharedPreferencesWipeJournal(), )); } diff --git a/apps/acs/test/main_boot_ca_test.dart b/apps/acs/test/main_boot_ca_test.dart index fb27229..2f936e7 100644 --- a/apps/acs/test/main_boot_ca_test.dart +++ b/apps/acs/test/main_boot_ca_test.dart @@ -34,7 +34,9 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:sinalacs_acs/core/network/backend_config.dart'; import 'package:sinalacs_acs/main.dart' as app; @@ -67,6 +69,13 @@ void main() { }); } + // O diário da limpeza remota (#81) e o id da instalação do `main.dart` + // real, sem pedido nenhum. + setUp(() { + SharedPreferences.setMockInitialValues({}); + FlutterSecureStorage.setMockInitialValues({}); + }); + tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMessageHandler('flutter/assets', null); @@ -90,7 +99,12 @@ void main() { }; try { await app.main(defaultHost: defaultHost); - await tester.pump(); + // O login só aparece depois do diário e da consulta da limpeza remota + // (#81): diário vazio e Keystore em memória (abaixo); a consulta falha + // sem servidor em poucos quadros, e o login abre. + for (var i = 0; i < 50 && find.byKey(const Key('matricula_field')).evaluate().isEmpty; i++) { + await tester.pump(); + } } finally { debugPrint = originalDebugPrint; } diff --git a/apps/acs/test/remote_wipe_ui_test.dart b/apps/acs/test/remote_wipe_ui_test.dart new file mode 100644 index 0000000..8cf14db --- /dev/null +++ b/apps/acs/test/remote_wipe_ui_test.dart @@ -0,0 +1,287 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/app/app_lock_gate.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; +import 'package:sinalacs_acs/core/security/upload_token_store.dart'; +import 'package:sinalacs_acs/core/services/alert_queue.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_acs/core/services/remote_wipe.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +import 'support/fakes.dart'; +import 'support/layout_harness.dart'; + +/// Armazenamento em memória que conta as destruições do banco (o apagador +/// real chama `destroyAll` uma vez por limpeza). +class _CountingStorage implements VisitStorage { + final InMemoryVisitStorage inner = InMemoryVisitStorage(); + int destroyCalls = 0; + + @override + VisitStore forOwner(String ownerId) => inner.forOwner(ownerId); + + @override + LegacyVisitStore get legacy => inner.legacy; + + @override + Future> countsByOwner() => inner.countsByOwner(); + + @override + Future wipeAllData() => inner.wipeAllData(); + + @override + Future destroyAll() async { + destroyCalls++; + await inner.destroyAll(); + } +} + +/// Limpeza remota no app (#81, Task 5): partida, retomada, volta da rede e +/// renovação levam à mesma tela `device_wiped`, fora do bloqueio. Ids +/// sintéticos. +void main() { + const idAntigo = '00000000-0000-4000-8000-0000000000d1'; + const ordem = 'ordem-1'; + + late FakeAcsBackend backend; + late MemoryDeviceIdStore deviceIds; + late MemoryWipeJournal journal; + late MemoryUploadTokenStore tokens; + late _CountingStorage storage; + late StreamController rede; + late List feeds; + + setUp(() { + deviceIds = MemoryDeviceIdStore(idAntigo); + backend = FakeAcsBackend()..deviceIds = deviceIds; + journal = MemoryWipeJournal(); + tokens = MemoryUploadTokenStore({'acs-que-saiu': 'upload-sintetico'}); + storage = _CountingStorage(); + rede = StreamController.broadcast(); + feeds = []; + }); + + tearDown(() => rede.close()); + + Future abrirApp(WidgetTester tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: storage, + uploadTokens: tokens, + deviceIds: deviceIds, + wipeJournal: journal, + connectivityChanges: rede.stream, + biometricGate: FakeBiometricGate(), + feedBuilder: (AlertQueue q) { + final feed = FakeAlertFeed(q); + feeds.add(feed); + return feed; + }, + )); + await assentar(tester); + } + + Future entrar(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + final botao = find.byKey(const Key('login_button')); + await tester.ensureVisible(botao); + await tester.pump(); + await tester.tap(botao); + await assentar(tester); + expect(find.text('Painel operacional'), findsOneWidget); + } + + void esperarTelaDeLimpeza() { + expect(find.byKey(const Key('device_wiped')), findsOneWidget); + expect(find.text('Aparelho limpo pela coordenação'), findsOneWidget); + expect( + find.text('Os dados do SinalACS deste aparelho foram apagados a pedido da coordenação da UBS, ' + 'inclusive as visitas que ainda não tinham sido enviadas. Os alertas continuam com a equipe da UBS.'), + findsOneWidget, + ); + expect(find.text('Painel operacional'), findsNothing); + expect(find.byType(LoginScreen), findsNothing); + // Fora do bloqueio por inatividade. + expect(find.byType(AppLockGate), findsNothing); + } + + testWidgets('pedido na partida apaga antes do login e mostra device_wiped', (tester) async { + backend.pendingWipeOrderId = ordem; + backend.storedRefreshToken = 'refresh-sintetico'; + + await abrirApp(tester); + + esperarTelaDeLimpeza(); + expect(backend.resumeCount, 0, reason: 'a tela de login não chegou a retomar a sessão'); + expect(storage.destroyCalls, 1); + expect(backend.forgetLocalSessionCount, 1); + expect(await tokens.owners(), isEmpty); + expect(await deviceIds.readOrCreate(), isNot(idAntigo)); + expect(backend.confirmedWipes, [(deviceId: idAntigo, orderId: ordem)]); + expect(await journal.read(), isNull); + expect(find.byKey(const Key('device_wiped_pending_confirm')), findsNothing); + // INV-03 e sem revogar o que o servidor já revogou. + expect(backend.acknowledgedAlertIds, isEmpty); + expect(backend.resolvedAlertIds, isEmpty); + expect(backend.revokedUploadTokens, isEmpty); + }); + + testWidgets('diário em erasing termina a limpeza na partida e mostra device_wiped', (tester) async { + await journal.write(ordem, idAntigo, WipePhase.erasing); + backend.storedRefreshToken = 'refresh-sintetico'; + + await abrirApp(tester); + + esperarTelaDeLimpeza(); + expect(backend.resumeCount, 0); + expect(storage.destroyCalls, 1); + expect(backend.confirmedWipes, [(deviceId: idAntigo, orderId: ordem)]); + }); + + testWidgets('wipe remoto com painel aberto desmonta o painel e mostra device_wiped', (tester) async { + backend.patients = [ + MicroAreaPatient(patientId: syntheticPatientId(5), name: 'Paciente Sintético', isChronic: false, chronicConditions: const []), + ]; + await abrirApp(tester); + await entrar(tester); + await irParaDaBarra(tester, 'Visita'); + await tester.ensureVisible(find.byKey(const Key('patient_search'))); + await tester.pump(); + await tester.enterText(find.byKey(const Key('patient_search')), 'busca sintetica'); + await tester.pump(); + final feed = feeds.single; + expect(feed.stopped, isFalse); + + backend.pendingWipeOrderId = ordem; + // A sequência completa (o campo de texto aberto escuta o ciclo e recusa + // pular estados): segundo plano e volta. + for (final estado in [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + AppLifecycleState.hidden, + AppLifecycleState.inactive, + AppLifecycleState.resumed, + ]) { + tester.binding.handleAppLifecycleStateChanged(estado); + } + await assentar(tester); + + esperarTelaDeLimpeza(); + expect(tester.takeException(), isNull); + expect(feed.stopped, isTrue, reason: 'o feed de alertas fecha com o painel'); + expect(storage.destroyCalls, 1); + expect(await storage.inner.forOwner(seedAcsId).load(), isEmpty, reason: 'a fila em RAM é descartada sem gravar'); + expect(backend.confirmedWipes, [(deviceId: idAntigo, orderId: ordem)]); + expect(backend.acknowledgedAlertIds, isEmpty); + expect(backend.resolvedAlertIds, isEmpty); + + // O painel não volta a rodar nada depois de desmontado. + final consultas = backend.openAlertsCalls; + await tester.pump(const Duration(minutes: 10)); + await assentar(tester); + expect(backend.openAlertsCalls, consultas); + expect(tester.takeException(), isNull); + }); + + testWidgets('renovação com pedido leva à mesma tela, sem tela de reautenticação', (tester) async { + await abrirApp(tester); + await entrar(tester); + + // O `BackendClient` real faz isto quando a renovação recebe + // `DeviceWipeRequiredException`: avisa o coordenador e lança. + backend.openAlertsFailure = const DeviceWipeRequiredFailure(ordem); + backend.onDeviceWipeRequired!(ordem); + await assentar(tester); + + esperarTelaDeLimpeza(); + expect(tester.takeException(), isNull); + expect(storage.destroyCalls, 1); + expect(backend.confirmedWipes, [(deviceId: idAntigo, orderId: ordem)]); + }); + + testWidgets('Entrar de novo abre login com deviceId novo', (tester) async { + backend.pendingWipeOrderId = ordem; + await abrirApp(tester); + esperarTelaDeLimpeza(); + backend.pendingWipeOrderId = null; // o id novo não tem pedido + + final botao = find.byKey(const Key('device_wiped_login')); + expect(find.descendant(of: botao, matching: find.text('Entrar de novo')), findsOneWidget); + await tester.ensureVisible(botao); + await tester.tap(botao); + await assentar(tester); + + expect(find.byKey(const Key('device_wiped')), findsNothing); + expect(find.byType(AppLockGate), findsOneWidget, reason: 'o bloqueio volta a valer'); + await entrar(tester); + expect(backend.loginDeviceIds, hasLength(1)); + expect(backend.loginDeviceIds.single, isNot(idAntigo)); + expect(storage.destroyCalls, 1); + }); + + testWidgets('conexão que volta pergunta pendingWipe', (tester) async { + await abrirApp(tester); + final antes = backend.pendingWipeQueries.length; + expect(antes, 1, reason: 'a partida pergunta uma vez'); + + rede.add(false); + await assentar(tester); + expect(backend.pendingWipeQueries.length, antes); + + backend.pendingWipeOrderId = ordem; + rede.add(true); + await assentar(tester); + + expect(backend.pendingWipeQueries.length, antes + 1); + esperarTelaDeLimpeza(); + }); + + testWidgets('confirmação sem rede mostra a linha pendente, reenviada quando a conexão volta', (tester) async { + backend.pendingWipeOrderId = ordem; + backend.confirmWipeFailure = const BackendFailure('Sem conexão com o servidor.'); + + await abrirApp(tester); + + esperarTelaDeLimpeza(); + expect( + find.text('A confirmação ainda não chegou ao servidor; será enviada quando houver conexão.'), + findsOneWidget, + ); + expect(find.byKey(const Key('device_wiped_pending_confirm')), findsOneWidget); + expect((await journal.read())?.phase, WipePhase.erased); + + backend.confirmWipeFailure = null; + backend.pendingWipeOrderId = null; + rede.add(false); + rede.add(true); + await assentar(tester); + + expect(backend.confirmedWipes, [(deviceId: idAntigo, orderId: ordem)]); + expect(await journal.read(), isNull); + expect(find.byKey(const Key('device_wiped_pending_confirm')), findsNothing); + expect(storage.destroyCalls, 1, reason: 'reenviar a confirmação não apaga de novo'); + }); + + testWidgets('fonte 200% em 320x640 sem estouro na device_wiped', (tester) async { + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + redimensionar(tester, const Size(320, 640), escalaDeFonte: 2.0); + backend.pendingWipeOrderId = ordem; + backend.confirmWipeFailure = const BackendFailure('Sem conexão com o servidor.'); + + await abrirApp(tester); + + esperarTelaDeLimpeza(); + expect(find.byKey(const Key('device_wiped_pending_confirm')), findsOneWidget); + esperarSemEstouro(tester, 'device_wiped a 200%'); + final alvo = tester.getSize(find.byKey(const Key('device_wiped_login'))); + expect(alvo.height, greaterThanOrEqualTo(52)); + expect(alvo.width, greaterThanOrEqualTo(48)); + }); +} diff --git a/apps/acs/test/session_store_wiring_test.dart b/apps/acs/test/session_store_wiring_test.dart index c90f1f6..7370403 100644 --- a/apps/acs/test/session_store_wiring_test.dart +++ b/apps/acs/test/session_store_wiring_test.dart @@ -25,6 +25,11 @@ void main() { expect(fonte, contains('connectivityChanges: Connectivity()')); }); + test('o main.dart liga o diário da limpeza remota ao shared_preferences (#81)', () { + final fonte = File('lib/main.dart').readAsStringSync(); + expect(fonte, contains('wipeJournal: SharedPreferencesWipeJournal()')); + }); + test('o caminho importado pelo backend_client não puxa o Flutter', () { for (final arquivo in [ 'lib/core/security/session_token_store.dart', diff --git a/apps/acs/test/support/fakes.dart b/apps/acs/test/support/fakes.dart index 8917ac5..e1988e9 100644 --- a/apps/acs/test/support/fakes.dart +++ b/apps/acs/test/support/fakes.dart @@ -144,6 +144,13 @@ class FakeAcsBackend implements AcsBackend { /// Quantas chamadas a [beginTotpEnrollment] ainda falham antes de funcionar. int enrollmentFailuresLeft = 0; + /// Id da instalação que o [login] lê, como o `BackendClient` real (#81). + /// `null`: o login não registra id nenhum. + DeviceIdStore? deviceIds; + + /// `deviceId` lido a cada [login], na ordem. + final List loginDeviceIds = []; + @override Future login({ required String matricula, @@ -151,6 +158,8 @@ class FakeAcsBackend implements AcsBackend { String? totpCode, }) async { loginCount++; + final ids = deviceIds; + if (ids != null) loginDeviceIds.add(await ids.readOrCreate()); lastCredentials = (matricula: matricula, senha: senha); lastTotpCode = totpCode; if (sessionExpired && expectedTotpCode != null && totpCode == null) throw const MfaCodeRequired(); From a9f2814b7f5fe45d0f6ec294bd63db2e056f1a5b Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 15:49:38 -0400 Subject: [PATCH 09/17] =?UTF-8?q?fix(acs):=20limpeza=20remota=20sela=20as?= =?UTF-8?q?=20filas=20antes=20de=20apagar=20e=20n=C3=A3o=20espera=20quadro?= =?UTF-8?q?=20em=20segundo=20plano=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/acs/lib/app/app.dart | 55 +++++++++------ .../core/services/offline_visit_queue.dart | 24 ++++++- .../test/offline_visit_queue_seal_test.dart | 68 +++++++++++++++++++ apps/acs/test/remote_wipe_ui_test.dart | 54 ++++++++++++++- 4 files changed, 178 insertions(+), 23 deletions(-) create mode 100644 apps/acs/test/offline_visit_queue_seal_test.dart diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index 347d808..2d12d48 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -160,10 +160,11 @@ const wipeRevokeTimeout = Duration(seconds: 5); /// limpeza acontece por cima do login. const startupWipeCheckTimeout = Duration(seconds: 5); -/// Teto da espera por um envio da fila do painel já em voo antes da limpeza -/// remota: o fim dele grava no disco, e gravar DEPOIS da destruição recriaria -/// o banco com as visitas que a coordenação mandou apagar. -const remoteWipeSyncWaitTimeout = Duration(seconds: 5); +/// Teto de cada espera de quadro na desmontagem do painel antes da limpeza +/// remota. Em segundo plano o Flutter não desenha quadros: sem teto, a +/// limpeza ficaria parada até o app voltar. Com a fila já selada, apagar antes +/// de o painel sair é seguro; ele sai no próximo quadro. +const remoteWipeFrameWait = Duration(milliseconds: 500); /// O que o painel pede ao app ao encerrar o turno ou limpar o aparelho: o app /// é dono do armazenamento, das filas memoizadas e do envio diferido. @@ -631,22 +632,23 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i _wipePendingConfirm.value = entry?.phase == WipePhase.erased; } - /// O apagador ([DeviceEraser]) do coordenador. Idempotente. Antes de apagar, - /// desmonta o painel (feed MQTT, reserva por HTTP, timers e formulários saem - /// com ele) e espera o envio da fila que já estava em voo, com teto; depois - /// descarta as filas em memória SEM gravar e apaga, nesta ordem: a sessão - /// local, o banco e a chave do SQLCipher, os tokens de envio (sem revogar: o - /// servidor já os revogou no pedido) e o id da instalação. D2: ao contrário - /// do "Limpar este aparelho", nada aqui recusa por visita não enviada. + /// O apagador ([DeviceEraser]) do coordenador. Idempotente. Primeiro SELA + /// toda fila em memória ([OfflineVisitQueue.seal]): um envio em voo que + /// termine depois da destruição gravaria de novo, num banco recriado com + /// chave nova, as visitas que a coordenação mandou apagar. Depois desmonta o + /// painel (feed MQTT, reserva por HTTP, timers e formulários saem com ele; + /// em segundo plano, no próximo quadro), descarta as filas SEM gravar e + /// apaga, nesta ordem: a sessão local, o banco e a chave do SQLCipher, os + /// tokens de envio (sem revogar: o servidor já os revogou no pedido) e o id + /// da instalação. D2: ao contrário do "Limpar este aparelho", nada aqui + /// recusa por visita não enviada. Future _eraseDevice() async { _wiping = true; try { - await _tearDownForWipe(); - final emVoo = {..._queues.values, for (final scope in _scopes.values) scope.queue}; - final limite = DateTime.now().add(remoteWipeSyncWaitTimeout); - while (emVoo.any((queue) => queue.isSyncing) && DateTime.now().isBefore(limite)) { - await Future.delayed(const Duration(milliseconds: 50)); + for (final queue in {..._queues.values, for (final scope in _scopes.values) scope.queue}) { + queue.seal(); } + await _tearDownForWipe(); final owners = {..._queues.keys}; _queues.clear(); _scopes.clear(); @@ -666,18 +668,23 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i } await widget.deviceIds!.delete(); } catch (_) { + // As filas já estão seladas: nenhuma serve ao próximo login, que relê o + // disco (o diário segue em `erasing` e a limpeza é refeita depois). + _queues.clear(); + _scopes.clear(); _wiping = false; if (mounted) _openFreshLogin(); rethrow; } } - /// Tira o painel (e tudo o mais) da pilha e espera os quadros em que os - /// `State`s saem: nenhum `setState` do painel roda depois disto. + /// Tira o painel (e tudo o mais) da pilha e espera, com teto, os quadros em + /// que os `State`s saem. Sem quadros (segundo plano), não espera: a fila já + /// está selada, e o painel sai no primeiro quadro da volta. Future _tearDownForWipe() async { var nav = _navigatorKey.currentState; if (nav == null) { - await WidgetsBinding.instance.endOfFrame; + await _frame(); nav = _navigatorKey.currentState; } if (nav == null || !mounted) return; @@ -689,8 +696,14 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i ), (_) => false, ); - await WidgetsBinding.instance.endOfFrame; - await WidgetsBinding.instance.endOfFrame; + await _frame(); + await _frame(); + } + + Future _frame() async { + final binding = WidgetsBinding.instance; + if (!binding.framesEnabled) return; + await binding.endOfFrame.timeout(remoteWipeFrameWait, onTimeout: () {}); } /// `onWiped`: a pilha inteira vira a tela `device_wiped`, fora do bloqueio. diff --git a/apps/acs/lib/core/services/offline_visit_queue.dart b/apps/acs/lib/core/services/offline_visit_queue.dart index a54b8c8..e5127ce 100644 --- a/apps/acs/lib/core/services/offline_visit_queue.dart +++ b/apps/acs/lib/core/services/offline_visit_queue.dart @@ -447,6 +447,16 @@ class OfflineVisitQueue { bool _restored = false; bool _persistenceFailed = false; + bool _sealed = false; + + /// Limpeza remota (#81): a partir daqui a fila nunca mais grava no disco — + /// nem pelo fim de um envio que já estava em voo, que recriaria o banco + /// apagado (com chave nova) com as visitas que a coordenação mandou apagar. + /// Registrar lança; enviar e resolver não fazem nada. Irreversível: o app + /// descarta a fila selada e monta outra depois do próximo login. + void seal() => _sealed = true; + + bool get isSealed => _sealed; /// `true` quando o armazenamento do dispositivo não está aceitando gravação. /// @@ -499,6 +509,7 @@ class OfflineVisitQueue { } Future add(OfflineVisitRecord record) async { + if (_sealed) throw StateError('Fila de visitas encerrada pela limpeza do aparelho.'); _pending.add(record); await _persist(); } @@ -510,7 +521,7 @@ class OfflineVisitQueue { /// confirmação apagaria evidência de uma visita feita que nunca chegou ao /// servidor. Future discardRejected() async { - if (_rejected.isEmpty) return; + if (_sealed || _rejected.isEmpty) return; _rejected.clear(); await _persist(); } @@ -519,6 +530,7 @@ class OfflineVisitQueue { /// 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 { + if (_sealed) return; final before = _conflicts.length; _conflicts.removeWhere((visit) => visit.localId == localId); if (_conflicts.length == before) return; @@ -538,12 +550,14 @@ class OfflineVisitQueue { /// 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}) { + if (_sealed) return Future.value(_sealedOutcome); 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: (_) {}); + if (_sealed) return _sealedOutcome; return _resolveKeepLocalNow(localId, outcome: outcome, notes: notes); }() .whenComplete(() { @@ -670,7 +684,14 @@ class OfflineVisitQueue { /// 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. + static const _sealedOutcome = SyncOutcome( + kind: SyncOutcomeKind.error, + processed: 0, + message: 'Fila de visitas encerrada pela limpeza do aparelho.', + ); + Future _persist() async { + if (_sealed) return; try { await _store.save([..._pending, ..._conflicts, ..._rejected]); _persistenceFailed = false; @@ -695,6 +716,7 @@ class OfflineVisitQueue { Future sync() { final inFlight = _syncing; if (inFlight != null) return inFlight; + if (_sealed) return Future.value(_sealedOutcome); late final Future run; run = _syncOnce().whenComplete(() { if (identical(_syncing, run)) _syncing = null; diff --git a/apps/acs/test/offline_visit_queue_seal_test.dart b/apps/acs/test/offline_visit_queue_seal_test.dart new file mode 100644 index 0000000..3061b6c --- /dev/null +++ b/apps/acs/test/offline_visit_queue_seal_test.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; + +import 'support/fakes.dart'; + +/// Envio que só responde quando o teste solta o portão (rede lenta). +class _SlowSynchronizer implements VisitSynchronizer { + final Completer gate = Completer(); + String status = 'synced'; + + @override + Future> push(List visits) async { + await gate.future; + return [ + for (final visit in visits) + VisitSyncOutcome(localId: visit.localId, status: status, serverVersion: visit.version + 1, message: 'recusada'), + ]; + } +} + +/// Fila selada pela limpeza remota (#81): nada do que ela tem em RAM volta ao +/// disco, nem pelo fim de um envio que já estava em voo. Ids sintéticos. +void main() { + OfflineVisitRecord visita(String localId) => + OfflineVisitRecord(localId: localId, patientId: seedPatientId, risk: 'green', status: 'PENDENTE'); + + for (final status in ['error', 'rejected', 'synced']) { + test('envio em voo que termina ($status) depois do seal não grava nada', () async { + final store = InMemoryVisitStore(); + final sender = _SlowSynchronizer()..status = status; + final queue = OfflineVisitQueue(store: store, synchronizer: sender); + await queue.add(visita('v-1')); + await queue.add(visita('v-2')); + final emVoo = queue.sync(); + + queue.seal(); + // A limpeza apaga o banco enquanto o envio está em voo. + await store.save(const []); + sender.gate.complete(); + await emVoo; + + expect(await store.load(), isEmpty); + expect(queue.isSealed, isTrue); + }); + } + + test('fila selada recusa registrar, resolver e enviar', () async { + final store = InMemoryVisitStore(); + final sender = FakeVisitSynchronizer(); + final queue = OfflineVisitQueue(store: store, synchronizer: sender); + await queue.add(visita('v-1')); + queue.seal(); + await store.save(const []); + + await expectLater(queue.add(visita('v-2')), throwsStateError); + await queue.discardRejected(); + await queue.resolveKeepServer('v-1'); + final keepLocal = await queue.resolveKeepLocal('v-1'); + final envio = await queue.sync(); + + expect(keepLocal.kind, SyncOutcomeKind.error); + expect(envio.processed, 0); + expect(sender.batches, isEmpty, reason: 'nada sai de uma fila selada'); + expect(await store.load(), isEmpty); + }); +} diff --git a/apps/acs/test/remote_wipe_ui_test.dart b/apps/acs/test/remote_wipe_ui_test.dart index 8cf14db..54d95b6 100644 --- a/apps/acs/test/remote_wipe_ui_test.dart +++ b/apps/acs/test/remote_wipe_ui_test.dart @@ -10,7 +10,7 @@ import 'package:sinalacs_acs/core/security/upload_token_store.dart'; import 'package:sinalacs_acs/core/services/alert_queue.dart'; import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; import 'package:sinalacs_acs/core/services/remote_wipe.dart'; -import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient, SyncStatus, VisitSyncResult; import 'support/fakes.dart'; import 'support/layout_harness.dart'; @@ -147,6 +147,10 @@ void main() { backend.patients = [ MicroAreaPatient(patientId: syntheticPatientId(5), name: 'Paciente Sintético', isChronic: false, chronicConditions: const []), ]; + // Uma visita pendente do ACS, que o painel carrega para a RAM ao abrir. + await storage.inner.forOwner(seedAcsId).save([ + OfflineVisitRecord(localId: 'v-ram', patientId: seedPatientId, risk: 'green', status: 'PENDENTE'), + ]); await abrirApp(tester); await entrar(tester); await irParaDaBarra(tester, 'Visita'); @@ -157,6 +161,19 @@ void main() { final feed = feeds.single; expect(feed.stopped, isFalse); + // Envio da fila em voo (rede lenta), que só responde DEPOIS da limpeza — + // e responde "recusada", o que faria a fila regravar a visita no disco. + final rede = Completer(); + backend.syncGate = rede; + backend.syncResultFor = (e) => + VisitSyncResult(localId: e.localId, syncStatus: SyncStatus.rejected, message: 'recusa sintética'); + final sincronizar = find.byKey(const Key('sync_visits')); + await tester.ensureVisible(sincronizar); + await tester.pump(); + await tester.tap(sincronizar); + await tester.pump(); + expect(backend.syncedVisitBatches, hasLength(1), reason: 'o envio saiu e está em voo'); + backend.pendingWipeOrderId = ordem; // A sequência completa (o campo de texto aberto escuta o ciclo e recusa // pular estados): segundo plano e volta. @@ -181,6 +198,13 @@ void main() { expect(backend.acknowledgedAlertIds, isEmpty); expect(backend.resolvedAlertIds, isEmpty); + // O envio em voo responde agora, depois do `destroyAll`: a fila selada + // não grava, e o banco recriado continua vazio. + rede.complete(); + await assentar(tester); + expect(await storage.inner.forOwner(seedAcsId).load(), isEmpty, reason: 'o fim do envio não traz a visita de volta'); + expect(await storage.inner.countsByOwner(), isEmpty); + // O painel não volta a rodar nada depois de desmontado. final consultas = backend.openAlertsCalls; await tester.pump(const Duration(minutes: 10)); @@ -189,6 +213,34 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('pedido com o app em segundo plano apaga sem esperar quadro', (tester) async { + await abrirApp(tester); + await entrar(tester); + for (final estado in [AppLifecycleState.inactive, AppLifecycleState.hidden, AppLifecycleState.paused]) { + tester.binding.handleAppLifecycleStateChanged(estado); + } + expect(tester.binding.framesEnabled, isFalse); + + // Renovação recusada em segundo plano (reserva de alertas, reconexão). + backend.onDeviceWipeRequired!(ordem); + // Sem nenhum `pump`: nenhum quadro é desenhado, só as microtarefas correm. + for (var i = 0; i < 100 && backend.confirmedWipes.isEmpty; i++) { + await Future.value(); + } + + expect(storage.destroyCalls, 1, reason: 'o banco é apagado sem esperar a volta do app'); + expect(backend.forgetLocalSessionCount, 1); + expect(await deviceIds.readOrCreate(), isNot(idAntigo)); + expect(backend.confirmedWipes, [(deviceId: idAntigo, orderId: ordem)]); + + for (final estado in [AppLifecycleState.hidden, AppLifecycleState.inactive, AppLifecycleState.resumed]) { + tester.binding.handleAppLifecycleStateChanged(estado); + } + await assentar(tester); + esperarTelaDeLimpeza(); + expect(tester.takeException(), isNull); + }); + testWidgets('renovação com pedido leva à mesma tela, sem tela de reautenticação', (tester) async { await abrirApp(tester); await entrar(tester); From 1ead368eeab4b0a2f5d2e1d77931ff4ae4c5f18c Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 15:57:27 -0400 Subject: [PATCH 10/17] feat(admin): aparelhos do ACS e limpeza remota pelo backoffice (#81) --- apps/admin/lib/app/acs_devices_dialog.dart | 267 ++++++++++++++++++ apps/admin/lib/app/admin_accounts.dart | 19 ++ .../lib/core/data/admin_data_source.dart | 62 ++++ .../core/data/backend_admin_data_source.dart | 38 +++ .../lib/core/data/mock_admin_data_source.dart | 53 ++++ apps/admin/test/acs_devices_dialog_test.dart | 166 +++++++++++ .../test/admin_accounts_data_source_test.dart | 71 +++++ .../test/alerts_filter_dynamic_test.dart | 10 + .../support/failing_admin_data_source.dart | 10 + apps/admin/test/touch_targets_test.dart | 28 ++ 10 files changed, 724 insertions(+) create mode 100644 apps/admin/lib/app/acs_devices_dialog.dart create mode 100644 apps/admin/test/acs_devices_dialog_test.dart diff --git a/apps/admin/lib/app/acs_devices_dialog.dart b/apps/admin/lib/app/acs_devices_dialog.dart new file mode 100644 index 0000000..c09cf2f --- /dev/null +++ b/apps/admin/lib/app/acs_devices_dialog.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart'; + +import 'package:sinalacs_admin/app/admin_accounts.dart'; +import 'package:sinalacs_admin/app/admin_theme.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; + +/// Aparelhos de um ACS e pedido de limpeza remota (#81). +/// +/// Lista cada aparelho com o último acesso e o estado do pedido, e oferece +/// "Limpar remotamente" nos que ainda não têm pedido. O pedido é irreversível +/// (sem cancelamento) e passa por duas etapas antes de chegar ao servidor: o +/// motivo (conjunto fechado) e a confirmação destrutiva +/// ([confirmarDestrutiva]). Cancelar em qualquer uma não chama o servidor. +/// A lista é sempre relida do servidor depois do pedido, nunca remendada. + +/// "DD/MM às HH:MM" na hora local. +String dataEHoraLocal(DateTime instante) { + final l = instante.toLocal(); + String dois(int n) => n.toString().padLeft(2, '0'); + return '${dois(l.day)}/${dois(l.month)} às ${dois(l.hour)}:${dois(l.minute)}'; +} + +String _horaLocal(DateTime instante) { + final l = instante.toLocal(); + String dois(int n) => n.toString().padLeft(2, '0'); + return '${dois(l.hour)}:${dois(l.minute)}'; +} + +/// Consequência mostrada antes de confirmar (D2: o remoto ignora o bloqueio de +/// visitas não enviadas, e o diálogo precisa dizer isso). +const consequenciaDaLimpezaRemota = + 'Limpar este aparelho remotamente? Ele perde o acesso agora e apaga os dados ' + 'do SinalACS na próxima vez que se conectar, inclusive as visitas que ainda ' + 'não foram enviadas. Isso não pode ser desfeito.'; + +/// Abre o diálogo dos aparelhos de [acs]. +Future mostrarAparelhosDoAcs( + BuildContext context, { + required AdminDataSource dataSource, + required AcsSummary acs, + VoidCallback? aoVencerSessao, +}) => + showDialog( + context: context, + builder: (_) => AcsDevicesDialog(dataSource: dataSource, acs: acs, aoVencerSessao: aoVencerSessao), + ); + +class AcsDevicesDialog extends StatefulWidget { + const AcsDevicesDialog({super.key, required this.dataSource, required this.acs, this.aoVencerSessao}); + + final AdminDataSource dataSource; + final AcsSummary acs; + final VoidCallback? aoVencerSessao; + + @override + State createState() => _AcsDevicesDialogState(); +} + +class _AcsDevicesDialogState extends State { + List? _aparelhos; + String? _erro; + String? _aviso; + bool _ocupado = false; + + @override + void initState() { + super.initState(); + _carregar(); + } + + /// Texto de uma falha: só o texto fixo do app ([AdminDataFailure]) chega à tela. + String _textoDaFalha(Object erro) => + erro is AdminDataFailure ? erro.message : 'Não foi possível concluir a ação. Tente novamente.'; + + void _venceu() { + if (!mounted) return; + Navigator.of(context).pop(); + widget.aoVencerSessao?.call(); + } + + Future _carregar() async { + try { + final lista = await widget.dataSource.fetchAcsDevices(widget.acs.id); + if (!mounted) return; + setState(() { + _aparelhos = lista; + _erro = null; + }); + } on AdminSessionExpired { + _venceu(); + } catch (erro) { + if (!mounted) return; + setState(() => _erro = _textoDaFalha(erro)); + } + } + + Future _limpar(AcsDevice aparelho) async { + if (_ocupado) return; + final motivo = await showDialog( + context: context, + builder: (_) => _EscolhaDoMotivo(aparelho: aparelho), + ); + if (motivo == null || !mounted) return; + final confirmado = await confirmarDestrutiva( + context, + titulo: 'Limpar ${aparelho.label}?', + consequencia: consequenciaDaLimpezaRemota, + rotuloConfirmar: 'Limpar aparelho', + ); + if (!confirmado || !mounted) return; + setState(() { + _ocupado = true; + _aviso = null; + _erro = null; + }); + try { + final r = await widget.dataSource.orderDeviceWipe( + acsId: widget.acs.id, + deviceId: aparelho.deviceId, + reason: motivo, + ); + if (!mounted) return; + setState(() => _aviso = r.alreadyOrdered ? 'Este aparelho já tinha um pedido de limpeza.' : null); + await _carregar(); + } on AdminSessionExpired { + _venceu(); + } catch (erro) { + if (mounted) setState(() => _erro = _textoDaFalha(erro)); + } finally { + if (mounted) setState(() => _ocupado = false); + } + } + + @override + Widget build(BuildContext context) { + final aparelhos = _aparelhos; + return AlertDialog( + scrollable: true, + title: Text('Aparelhos de ${widget.acs.name}'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_erro != null) + Semantics( + liveRegion: true, + child: Text(_erro!, key: const Key('aparelhos_erro'), style: const TextStyle(color: AdminColors.redOnSurface)), + ), + if (_aviso != null) + Semantics(liveRegion: true, child: Text(_aviso!, key: const Key('aparelhos_aviso'))), + if (aparelhos == null && _erro == null) + const Center(child: CircularProgressIndicator()) + else if (aparelhos != null && aparelhos.isEmpty) + const Text('Nenhum aparelho com acesso registrado para este ACS.') + else if (aparelhos != null) + for (final a in aparelhos) + _LinhaDoAparelho(aparelho: a, ocupado: _ocupado, aoLimpar: () => _limpar(a)), + ], + ), + ), + actions: [ + TextButton( + key: const Key('fechar_aparelhos'), + style: ButtonStyle(minimumSize: WidgetStateProperty.all(const Size(48, 52))), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fechar'), + ), + ], + ); + } +} + +class _LinhaDoAparelho extends StatelessWidget { + const _LinhaDoAparelho({required this.aparelho, required this.ocupado, required this.aoLimpar}); + + final AcsDevice aparelho; + final bool ocupado; + final VoidCallback aoLimpar; + + String? get _estado => switch (aparelho.wipeState) { + null => null, + DeviceWipeState.pending => aparelho.wipeRequestedAt == null + ? 'Limpeza pedida' + : 'Limpeza pedida às ${_horaLocal(aparelho.wipeRequestedAt!)}', + DeviceWipeState.delivered => 'Pedido entregue ao aparelho', + DeviceWipeState.confirmed => aparelho.wipeConfirmedAt == null + ? 'Aparelho limpo' + : 'Aparelho limpo às ${_horaLocal(aparelho.wipeConfirmedAt!)}', + }; + + @override + Widget build(BuildContext context) { + final estado = _estado; + return Padding( + key: Key('aparelho_${aparelho.deviceId}'), + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(aparelho.label, style: const TextStyle(fontWeight: FontWeight.bold)), + Text('Último acesso em ${dataEHoraLocal(aparelho.lastSeenAt)}'), + if (estado != null) Text(estado, key: Key('estado_${aparelho.deviceId}')), + if (aparelho.wipeState == null) ...[ + const SizedBox(height: 6), + OutlinedButton.icon( + key: Key('wipe_device_${aparelho.deviceId}'), + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: ocupado ? null : aoLimpar, + icon: const Icon(Icons.delete_sweep_outlined), + label: const Text('Limpar remotamente'), + ), + ], + ], + ), + ); + } +} + +/// Primeira etapa do pedido: o motivo, de um conjunto fechado. +class _EscolhaDoMotivo extends StatefulWidget { + const _EscolhaDoMotivo({required this.aparelho}); + + final AcsDevice aparelho; + + @override + State<_EscolhaDoMotivo> createState() => _EscolhaDoMotivoState(); +} + +class _EscolhaDoMotivoState extends State<_EscolhaDoMotivo> { + DeviceWipeReason? _motivo; + + @override + Widget build(BuildContext context) => AlertDialog( + scrollable: true, + title: Text('Limpar ${widget.aparelho.label}'), + content: SizedBox( + width: 360, + child: DropdownButtonFormField( + key: const Key('wipe_reason'), + initialValue: _motivo, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Motivo'), + hint: const Text('Escolha o motivo'), + items: [ + for (final m in DeviceWipeReason.values) DropdownMenuItem(value: m, child: Text(m.label)), + ], + onChanged: (v) => setState(() => _motivo = v), + ), + ), + actions: [ + TextButton( + key: const Key('cancelar_motivo'), + style: ButtonStyle(minimumSize: WidgetStateProperty.all(const Size(48, 52))), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancelar'), + ), + FilledButton( + key: const Key('continuar_motivo'), + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: _motivo == null ? null : () => Navigator.of(context).pop(_motivo), + child: const Text('Continuar'), + ), + ], + ); +} diff --git a/apps/admin/lib/app/admin_accounts.dart b/apps/admin/lib/app/admin_accounts.dart index a98608e..2dc4103 100644 --- a/apps/admin/lib/app/admin_accounts.dart +++ b/apps/admin/lib/app/admin_accounts.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:sinalacs_admin/app/acs_devices_dialog.dart'; import 'package:sinalacs_admin/app/admin_theme.dart'; import 'package:sinalacs_admin/core/data/admin_data_source.dart'; @@ -241,6 +242,13 @@ class _AcsManagementSectionState extends State with _Escri await escrever(() => widget.dataSource.resetAcsMfa(acsId: acs.id)); } + Future _aparelhos(AcsSummary acs) => mostrarAparelhosDoAcs( + context, + dataSource: widget.dataSource, + acs: acs, + aoVencerSessao: widget.aoVencerSessao, + ); + Future _mostrarCredencial({ required String titulo, required String explicacao, @@ -294,6 +302,7 @@ class _AcsManagementSectionState extends State with _Escri aoAlternarAcesso: () => _alternarAcesso(acs), aoRedefinirSenha: () => _redefinirSenha(acs), aoRedefinirMfa: () => _redefinirMfa(acs), + aoVerAparelhos: () => _aparelhos(acs), ), ), ], @@ -389,6 +398,7 @@ class _CartaoAcs extends StatelessWidget { required this.aoAlternarAcesso, required this.aoRedefinirSenha, required this.aoRedefinirMfa, + required this.aoVerAparelhos, }); final AcsSummary acs; @@ -397,6 +407,7 @@ class _CartaoAcs extends StatelessWidget { final VoidCallback aoAlternarAcesso; final VoidCallback aoRedefinirSenha; final VoidCallback aoRedefinirMfa; + final VoidCallback aoVerAparelhos; @override Widget build(BuildContext context) => Padding( @@ -455,6 +466,14 @@ class _CartaoAcs extends StatelessWidget { icon: const Icon(Icons.phonelink_lock_outlined), label: const Text('Redefinir MFA'), ), + OutlinedButton.icon( + key: Key('acs_devices_${acs.id}'), + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + // Só leitura: segue habilitado durante outra escrita. + onPressed: aoVerAparelhos, + icon: const Icon(Icons.smartphone_outlined), + label: const Text('Aparelhos'), + ), OutlinedButton.icon( key: Key('${acs.active ? 'desativar' : 'ativar'}_acs_${acs.id}'), style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), diff --git a/apps/admin/lib/core/data/admin_data_source.dart b/apps/admin/lib/core/data/admin_data_source.dart index eb3fed7..05d5897 100644 --- a/apps/admin/lib/core/data/admin_data_source.dart +++ b/apps/admin/lib/core/data/admin_data_source.dart @@ -484,6 +484,56 @@ class StaffSummary { final bool mfaActive; } +/// Espelha `DeviceWipeState` (#81). Traduzido **por nome**, nunca por índice. +enum DeviceWipeState { pending, delivered, confirmed } + +/// Espelha `DeviceWipeReason` (#81): conjunto fechado, nunca texto livre. +enum DeviceWipeReason { + lost('Perdido'), + stolen('Roubado'), + replaced('Substituído'); + + const DeviceWipeReason(this.label); + + /// Texto da tela, em português. + final String label; +} + +/// Espelha `AdminAcsDevice` (`admin_acs_device.spy.yaml`): um aparelho do ACS. +/// [label] é derivado do `deviceId` pelo servidor; sem pedido de limpeza, +/// [wipeState] é nulo. +class AcsDevice { + const AcsDevice({ + required this.deviceId, + required this.label, + required this.lastSeenAt, + this.wipeState, + this.wipeRequestedAt, + this.wipeConfirmedAt, + }); + + final String deviceId; + final String label; + final DateTime lastSeenAt; + final DeviceWipeState? wipeState; + final DateTime? wipeRequestedAt; + final DateTime? wipeConfirmedAt; +} + +/// Espelha `AdminDeviceWipeResult`. Com [alreadyOrdered], [requestedAt] é o +/// instante do pedido original. +class DeviceWipeOrderResult { + const DeviceWipeOrderResult({ + required this.orderId, + required this.requestedAt, + required this.alreadyOrdered, + }); + + final String orderId; + final DateTime requestedAt; + final bool alreadyOrdered; +} + /// Cadastro de ACS: a linha criada e a senha inicial, que **só** existe nesta /// resposta — o servidor guarda apenas o hash. class NewAcsCredential { @@ -657,6 +707,18 @@ abstract interface class AdminDataSource { /// Apaga o segredo TOTP do ACS, para ele ativar de novo na próxima entrada. Future resetAcsMfa({required String acsId}); + /// Aparelhos com acesso registrado do ACS e o estado do pedido de limpeza + /// (#81). Recusa fora do escopo como o servidor: texto fixo. + Future> fetchAcsDevices(String acsId); + + /// Pede a limpeza remota do aparelho (#81). Irreversível; repetir é + /// idempotente ([DeviceWipeOrderResult.alreadyOrdered]). + Future orderDeviceWipe({ + required String acsId, + required String deviceId, + required DeviceWipeReason reason, + }); + /// Redefine a MFA de uma conta de equipe (só o administrador, nunca a própria /// conta) e devolve o código de ativação novo. Future resetStaffMfa({required String staffId}); diff --git a/apps/admin/lib/core/data/backend_admin_data_source.dart b/apps/admin/lib/core/data/backend_admin_data_source.dart index f5144ac..589b948 100644 --- a/apps/admin/lib/core/data/backend_admin_data_source.dart +++ b/apps/admin/lib/core/data/backend_admin_data_source.dart @@ -150,6 +150,44 @@ class BackendAdminDataSource implements AdminDataSource { () => _admin.resetAcsMfa(accessToken: _accessToken, acsId: acsId), ); + @override + Future> fetchAcsDevices(String acsId) => _guard(() async { + final lista = await _admin.acsDevices(accessToken: _accessToken, acsId: acsId); + return [ + for (final d in lista) + AcsDevice( + deviceId: d.deviceId, + label: d.label, + lastSeenAt: d.lastSeenAt, + // Por nome, nunca por índice: a ordem do enum não é contrato. + wipeState: d.wipeState == null ? null : DeviceWipeState.values.byName(d.wipeState!.name), + wipeRequestedAt: d.wipeRequestedAt, + wipeConfirmedAt: d.wipeConfirmedAt, + ), + ]; + }, invalido: 'Aparelho não encontrado.'); + + @override + Future orderDeviceWipe({ + required String acsId, + required String deviceId, + required DeviceWipeReason reason, + }) => _guard(() async { + final r = await _admin.orderDeviceWipe( + accessToken: _accessToken, + acsId: acsId, + deviceId: deviceId, + reason: api.DeviceWipeReason.values.byName(reason.name), + ); + return DeviceWipeOrderResult( + orderId: r.orderId, + requestedAt: r.requestedAt, + alreadyOrdered: r.alreadyOrdered, + ); + // Texto fixo: a recusa do servidor ('Aparelho não encontrado.') é a mesma + // para fora do escopo e para aparelho alheio, e não vira texto de tela. + }, invalido: 'Aparelho não encontrado.'); + @override Future resetStaffMfa({required String staffId}) => _guardEscrita(() async { diff --git a/apps/admin/lib/core/data/mock_admin_data_source.dart b/apps/admin/lib/core/data/mock_admin_data_source.dart index dc8ef11..6522cef 100644 --- a/apps/admin/lib/core/data/mock_admin_data_source.dart +++ b/apps/admin/lib/core/data/mock_admin_data_source.dart @@ -75,6 +75,24 @@ class MockAdminDataSource implements AdminDataSource { final List senhasRedefinidas = []; final List mfasRedefinidas = []; + /// Pedidos de limpeza remota recebidos (#81): `acsId/deviceId` e o motivo. + final List<({String acsId, String deviceId, DeviceWipeReason reason})> limpezasPedidas = []; + + /// Aparelhos sintéticos por ACS. `acs-1` tem um sem pedido e um já limpo. + final Map> _aparelhos = { + 'acs-1': [ + AcsDevice(deviceId: 'dev-aaaa1111', label: 'Aparelho dev-aaaa', lastSeenAt: DateTime.utc(2026, 10, 16, 14, 5)), + AcsDevice( + deviceId: 'dev-bbbb2222', + label: 'Aparelho dev-bbbb', + lastSeenAt: DateTime.utc(2026, 10, 10, 9, 30), + wipeState: DeviceWipeState.confirmed, + wipeRequestedAt: DateTime.utc(2026, 10, 10, 10, 0), + wipeConfirmedAt: DateTime.utc(2026, 10, 10, 10, 20), + ), + ], + }; + /// Credenciais determinísticas: o teste de tela procura por este texto. static const _senhaDeTeste = 'SENHA-INICIAL-DE-TESTE'; static const _codigoDeAtivacao = 'ABCD-EFGH-JKLM-NPQR-STUV'; @@ -699,6 +717,41 @@ class MockAdminDataSource implements AdminDataSource { mfasRedefinidas.add(acsId); } + @override + Future> fetchAcsDevices(String acsId) async { + _indiceAcs(acsId); + return List.of(_aparelhos[acsId] ?? const []); + } + + @override + Future orderDeviceWipe({ + required String acsId, + required String deviceId, + required DeviceWipeReason reason, + }) async { + final lista = _aparelhos[acsId] ?? const []; + final i = lista.indexWhere((d) => d.deviceId == deviceId); + if (i < 0) throw const AdminDataFailure('Aparelho não encontrado.'); + final d = lista[i]; + if (d.wipeState != null) { + return DeviceWipeOrderResult( + orderId: 'ord-$deviceId', + requestedAt: d.wipeRequestedAt!, + alreadyOrdered: true, + ); + } + limpezasPedidas.add((acsId: acsId, deviceId: deviceId, reason: reason)); + final agora = DateTime.now().toUtc(); + lista[i] = AcsDevice( + deviceId: d.deviceId, + label: d.label, + lastSeenAt: d.lastSeenAt, + wipeState: DeviceWipeState.pending, + wipeRequestedAt: agora, + ); + return DeviceWipeOrderResult(orderId: 'ord-$deviceId', requestedAt: agora, alreadyOrdered: false); + } + @override Future resetStaffMfa({required String staffId}) async { final i = _indiceStaff(staffId); diff --git a/apps/admin/test/acs_devices_dialog_test.dart b/apps/admin/test/acs_devices_dialog_test.dart new file mode 100644 index 0000000..ccfa96a --- /dev/null +++ b/apps/admin/test/acs_devices_dialog_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/acs_devices_dialog.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; +import 'package:sinalacs_admin/core/data/mock_admin_data_source.dart'; + +import 'support/layout_harness.dart'; + +/// Duplo cujo pedido é recusado com o texto fixo do app (#81). +class _MockQueRecusa extends MockAdminDataSource { + @override + Future orderDeviceWipe({ + required String acsId, + required String deviceId, + required DeviceWipeReason reason, + }) async => throw const AdminDataFailure('Aparelho não encontrado.'); +} + +/// Duplo que já tinha o pedido quando o operador confirmou. +class _MockJaPedido extends MockAdminDataSource { + @override + Future orderDeviceWipe({ + required String acsId, + required String deviceId, + required DeviceWipeReason reason, + }) async => DeviceWipeOrderResult( + orderId: 'ord-1', + requestedAt: DateTime.utc(2026, 10, 17, 9, 0), + alreadyOrdered: true, + ); +} + +Future _abrirAparelhos( + WidgetTester tester, { + MockAdminDataSource? dataSource, + Size tamanho = const Size(1024, 768), + double escala = 1.0, +}) async { + await abrirBackoffice(tester, tamanho: tamanho, dataSource: dataSource ?? MockAdminDataSource(), escalaDeFonte: escala); + await irPara(tester, 'Microáreas'); + final botao = find.byKey(const Key('acs_devices_acs-1')); + if (botao.evaluate().isEmpty) { + await tester.scrollUntilVisible(botao, 320, scrollable: find.byType(Scrollable).last); + } + await tester.ensureVisible(botao); + await tester.pumpAndSettle(); + await tester.tap(botao); + await tester.pumpAndSettle(); +} + +Future _tocar(WidgetTester tester, String chave) async { + final alvo = find.byKey(Key(chave)); + await tester.ensureVisible(alvo); + await tester.pumpAndSettle(); + await tester.tap(alvo); + await tester.pumpAndSettle(); +} + +Future _escolherMotivo(WidgetTester tester, String rotulo) async { + await tester.tap(find.byKey(const Key('wipe_reason'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(rotulo).last); + await tester.pumpAndSettle(); + await _tocar(tester, 'continuar_motivo'); +} + +void main() { + testWidgets('lista aparelhos com estado e último acesso', (tester) async { + await _abrirAparelhos(tester); + + expect(find.text('Aparelho dev-aaaa'), findsOneWidget); + expect( + find.text('Último acesso em ${dataEHoraLocal(DateTime.utc(2026, 10, 16, 14, 5))}'), + findsOneWidget, + ); + // Sem pedido: sem linha de estado, com o botão. + expect(find.byKey(const Key('estado_dev-aaaa1111')), findsNothing); + expect(find.byKey(const Key('wipe_device_dev-aaaa1111')), findsOneWidget); + expect(find.text('Limpar remotamente'), findsOneWidget); + // Já limpo: estado visível e botão escondido. + expect(find.textContaining('Aparelho limpo às '), findsOneWidget); + expect(find.byKey(const Key('wipe_device_dev-bbbb2222')), findsNothing); + }); + + testWidgets('sem aparelho mostra o texto vazio', (tester) async { + await abrirBackoffice(tester, tamanho: const Size(1024, 768), dataSource: MockAdminDataSource()); + await irPara(tester, 'Microáreas'); + await _tocar(tester, 'acs_devices_acs-2'); + expect(find.text('Nenhum aparelho com acesso registrado para este ACS.'), findsOneWidget); + }); + + testWidgets('cancelar a confirmação não chama orderDeviceWipe', (tester) async { + final mock = MockAdminDataSource(); + await _abrirAparelhos(tester, dataSource: mock); + + await _tocar(tester, 'wipe_device_dev-aaaa1111'); + await _escolherMotivo(tester, 'Roubado'); + expect(find.text(consequenciaDaLimpezaRemota), findsOneWidget); + expect(find.text('Limpar aparelho'), findsOneWidget); + await _tocar(tester, 'cancelar_acao'); + + expect(mock.limpezasPedidas, isEmpty); + expect(find.byKey(const Key('wipe_device_dev-aaaa1111')), findsOneWidget); + }); + + testWidgets('cancelar o motivo também não chama o servidor', (tester) async { + final mock = MockAdminDataSource(); + await _abrirAparelhos(tester, dataSource: mock); + + await _tocar(tester, 'wipe_device_dev-aaaa1111'); + await _tocar(tester, 'cancelar_motivo'); + + expect(mock.limpezasPedidas, isEmpty); + }); + + testWidgets('confirmar envia o motivo escolhido e recarrega a lista', (tester) async { + final mock = MockAdminDataSource(); + await _abrirAparelhos(tester, dataSource: mock); + + await _tocar(tester, 'wipe_device_dev-aaaa1111'); + await _escolherMotivo(tester, 'Substituído'); + await _tocar(tester, 'confirmar_acao'); + + expect(mock.limpezasPedidas, hasLength(1)); + expect(mock.limpezasPedidas.single.deviceId, 'dev-aaaa1111'); + expect(mock.limpezasPedidas.single.reason, DeviceWipeReason.replaced); + // Lista relida do servidor: agora com estado e sem o botão. + expect(find.textContaining('Limpeza pedida às '), findsOneWidget); + expect(find.byKey(const Key('wipe_device_dev-aaaa1111')), findsNothing); + expect(find.byKey(const Key('aparelhos_aviso')), findsNothing); + }); + + testWidgets('alreadyOrdered mostra o aviso', (tester) async { + await _abrirAparelhos(tester, dataSource: _MockJaPedido()); + + await _tocar(tester, 'wipe_device_dev-aaaa1111'); + await _escolherMotivo(tester, 'Perdido'); + await _tocar(tester, 'confirmar_acao'); + + expect(find.text('Este aparelho já tinha um pedido de limpeza.'), findsOneWidget); + }); + + testWidgets('recusa do servidor mostra texto fixo', (tester) async { + await _abrirAparelhos(tester, dataSource: _MockQueRecusa()); + + await _tocar(tester, 'wipe_device_dev-aaaa1111'); + await _escolherMotivo(tester, 'Perdido'); + await _tocar(tester, 'confirmar_acao'); + + expect(find.text('Aparelho não encontrado.'), findsOneWidget); + // O dialogo segue aberto e o botão volta, para tentar de novo. + expect(find.byKey(const Key('wipe_device_dev-aaaa1111')), findsOneWidget); + }); + + for (final largura in const [360.0, 320.0]) { + testWidgets('fonte 200% sem estouro em ${largura.toInt()}dp', (tester) async { + await _abrirAparelhos(tester, tamanho: Size(largura, 800), escala: 2.0); + esperarSemEstouroDeLayout(tester, 'diálogo de aparelhos (${largura.toInt()}dp, 200%)'); + + await _tocar(tester, 'wipe_device_dev-aaaa1111'); + esperarSemEstouroDeLayout(tester, 'motivo (${largura.toInt()}dp, 200%)'); + await _escolherMotivo(tester, 'Roubado'); + esperarSemEstouroDeLayout(tester, 'confirmação (${largura.toInt()}dp, 200%)'); + }); + } +} diff --git a/apps/admin/test/admin_accounts_data_source_test.dart b/apps/admin/test/admin_accounts_data_source_test.dart index 3967115..9f8c146 100644 --- a/apps/admin/test/admin_accounts_data_source_test.dart +++ b/apps/admin/test/admin_accounts_data_source_test.dart @@ -293,4 +293,75 @@ void main() { } }); }); + + group('aparelhos e limpeza remota (#81)', () { + test('acsDevices manda token e acsId e traduz o estado por nome', () async { + final caller = FakeEndpointCaller( + result: [ + api.AdminAcsDevice( + deviceId: 'dev-1', + label: 'Aparelho dev-1', + lastSeenAt: DateTime.utc(2026, 10, 16, 14, 5), + ), + api.AdminAcsDevice( + deviceId: 'dev-2', + label: 'Aparelho dev-2', + lastSeenAt: DateTime.utc(2026, 10, 15), + wipeState: api.DeviceWipeState.delivered, + wipeRequestedAt: DateTime.utc(2026, 10, 16), + ), + ], + ); + + final r = await _fonte(caller).fetchAcsDevices('acs-1'); + + expect(caller.lastArgs, {'accessToken': 'token-de-teste', 'acsId': 'acs-1'}); + expect(r.first.wipeState, isNull); + expect(r.last.wipeState, DeviceWipeState.delivered); + expect(r.last.wipeRequestedAt, DateTime.utc(2026, 10, 16)); + }); + + test('orderDeviceWipe traduz o motivo por nome', () async { + for (final motivo in DeviceWipeReason.values) { + final caller = FakeEndpointCaller( + result: api.AdminDeviceWipeResult( + orderId: 'ord-1', + requestedAt: DateTime.utc(2026, 10, 17), + alreadyOrdered: true, + ), + ); + + final r = await _fonte(caller).orderDeviceWipe(acsId: 'acs-1', deviceId: 'dev-1', reason: motivo); + + expect(caller.lastArgs!['reason'], api.DeviceWipeReason.values.byName(motivo.name), reason: motivo.name); + expect(caller.lastArgs!['acsId'], 'acs-1'); + expect(caller.lastArgs!['deviceId'], 'dev-1'); + expect(caller.lastArgs!['accessToken'], 'token-de-teste'); + expect(r.orderId, 'ord-1'); + expect(r.alreadyOrdered, isTrue); + } + }); + + test('a recusa do servidor vira texto fixo, nunca o do servidor', () async { + final caller = FakeEndpointCaller(error: api.AdminInvalidRequestException(message: 'texto interno do servidor')); + await expectLater( + _fonte(caller).orderDeviceWipe(acsId: 'a', deviceId: 'd', reason: DeviceWipeReason.lost), + throwsA(isA().having((e) => e.message, 'message', 'Aparelho não encontrado.')), + ); + await expectLater( + _fonte(caller).fetchAcsDevices('a'), + throwsA(isA().having((e) => e.message, 'message', 'Aparelho não encontrado.')), + ); + }); + + test('token recusado depois do vencimento vira AdminSessionExpired', () async { + final caller = FakeEndpointCaller(error: api.AlertPermissionException(message: 'x')); + final fonte = _fonte( + caller, + expiresAt: DateTime.utc(2026, 10, 17, 10), + now: () => DateTime.utc(2026, 10, 17, 11), + ); + await expectLater(fonte.fetchAcsDevices('a'), throwsA(isA())); + }); + }); } diff --git a/apps/admin/test/alerts_filter_dynamic_test.dart b/apps/admin/test/alerts_filter_dynamic_test.dart index 6707b6a..eff4a1e 100644 --- a/apps/admin/test/alerts_filter_dynamic_test.dart +++ b/apps/admin/test/alerts_filter_dynamic_test.dart @@ -25,6 +25,16 @@ class _CustomAreaDataSource implements AdminDataSource { required DateTime scheduledFor, }) => throw UnimplementedError(); + @override + Future> fetchAcsDevices(String acsId) => throw UnimplementedError(); + + @override + Future orderDeviceWipe({ + required String acsId, + required String deviceId, + required DeviceWipeReason reason, + }) => throw UnimplementedError(); + @override Future cancelScheduledNotice(String id) => throw UnimplementedError(); diff --git a/apps/admin/test/support/failing_admin_data_source.dart b/apps/admin/test/support/failing_admin_data_source.dart index cd2b649..ecfa4ad 100644 --- a/apps/admin/test/support/failing_admin_data_source.dart +++ b/apps/admin/test/support/failing_admin_data_source.dart @@ -142,6 +142,16 @@ class FailingAdminDataSource implements AdminDataSource { @override Future resetAcsPassword({required String acsId}) => inner.resetAcsPassword(acsId: acsId); + @override + Future> fetchAcsDevices(String acsId) => inner.fetchAcsDevices(acsId); + + @override + Future orderDeviceWipe({ + required String acsId, + required String deviceId, + required DeviceWipeReason reason, + }) => inner.orderDeviceWipe(acsId: acsId, deviceId: deviceId, reason: reason); + @override Future resetAcsMfa({required String acsId}) => inner.resetAcsMfa(acsId: acsId); diff --git a/apps/admin/test/touch_targets_test.dart b/apps/admin/test/touch_targets_test.dart index 3e112b3..dd8dab2 100644 --- a/apps/admin/test/touch_targets_test.dart +++ b/apps/admin/test/touch_targets_test.dart @@ -150,4 +150,32 @@ void main() { conferirAlvo(tester, find.byKey(const Key('confirmar_acao')), 'confirmar_acao'); conferirAlvo(tester, find.byKey(const Key('cancelar_acao')), 'cancelar_acao'); }); + testWidgets('alvos de toque 48x52 no diálogo de aparelhos', (tester) async { + await abrirMicroAreas(tester); + + final abrir = find.byKey(const Key('acs_devices_acs-1')); + await rolarAte(tester, abrir); + conferirAlvo(tester, abrir, 'acs_devices_acs-1'); + await tester.tap(abrir); + await tester.pumpAndSettle(); + + conferirAlvo(tester, find.byKey(const Key('fechar_aparelhos')), 'fechar_aparelhos'); + final limpar = find.byKey(const Key('wipe_device_dev-aaaa1111')); + await rolarAte(tester, limpar); + conferirAlvo(tester, limpar, 'wipe_device_dev-aaaa1111'); + + await tester.tap(limpar); + await tester.pumpAndSettle(); + conferirAlvo(tester, find.byKey(const Key('cancelar_motivo')), 'cancelar_motivo'); + conferirAlvo(tester, find.byKey(const Key('continuar_motivo')), 'continuar_motivo'); + await tester.tap(find.byKey(const Key('wipe_reason'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Perdido').last); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('continuar_motivo'))); + await tester.pumpAndSettle(); + + conferirAlvo(tester, find.byKey(const Key('confirmar_acao')), 'confirmar_acao'); + conferirAlvo(tester, find.byKey(const Key('cancelar_acao')), 'cancelar_acao'); + }); } From 8bbe69df44aa8c4d6963b38808938f6825c551b2 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 16:21:23 -0400 Subject: [PATCH 11/17] =?UTF-8?q?test(acs):=20e2e=20no=20emulador=20da=20l?= =?UTF-8?q?impeza=20remota=20com=20visita=20n=C3=A3o=20enviada=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remote_wipe_e2e.dart roda o SinalAcsApp com o id da instalação, a chave do SQLCipher, os tokens de envio e o diário de produção; deixa uma visita pendente (envio fora do ar pelo decorator, sem "Sair") com o painel aberto; o coordenador da fixture pede a limpeza pela rede real e a volta do segundo plano a aplica. Confere no aparelho banco vazio com chave nova, nenhum token de envio e id novo, e entra de novo pela tela. acs_remote_wipe_e2e.sh confere no banco o pedido entregue e confirmado, os tokens do id antigo revogados, a trilha dos três eventos, a visita ausente de visits e a cadeia de auditoria; acs_remote_wipe_e2e_test.sh guarda a estrutura sem Docker nem aparelho. O decorator de envio vai para support/envio_fora_do_ar.dart, compartilhado com full_journey_e2e.dart. A fila por dono do full_journey passa a reabrir o banco depois do "Limpar este aparelho": desde a D6 ele fecha e apaga o arquivo e troca a chave, e a conexão antiga do teste ficava fechada. --- .../integration_test/full_journey_e2e.dart | 121 +------- .../acs/integration_test/remote_wipe_e2e.dart | 287 ++++++++++++++++++ .../acs/integration_test/support/e2e_acs.dart | 18 ++ .../support/envio_fora_do_ar.dart | 110 +++++++ .../plans/2026-10-17-issue-81-e2e.log | 106 +++++++ scripts/qa/acs_remote_wipe_e2e.sh | 141 +++++++++ scripts/qa/acs_remote_wipe_e2e_test.sh | 67 ++++ 7 files changed, 741 insertions(+), 109 deletions(-) create mode 100644 apps/acs/integration_test/remote_wipe_e2e.dart create mode 100644 apps/acs/integration_test/support/envio_fora_do_ar.dart create mode 100644 docs/superpowers/plans/2026-10-17-issue-81-e2e.log create mode 100755 scripts/qa/acs_remote_wipe_e2e.sh create mode 100755 scripts/qa/acs_remote_wipe_e2e_test.sh diff --git a/apps/acs/integration_test/full_journey_e2e.dart b/apps/acs/integration_test/full_journey_e2e.dart index 4affa77..a6e76f3 100644 --- a/apps/acs/integration_test/full_journey_e2e.dart +++ b/apps/acs/integration_test/full_journey_e2e.dart @@ -68,6 +68,7 @@ import 'package:sinalacs_acs/core/services/visit_queue_factory.dart'; import 'package:sinalacs_client/sinalacs_client.dart' as api; import 'support/e2e_acs.dart'; +import 'support/envio_fora_do_ar.dart'; import 'support/legacy_v6.dart'; import 'support/totp.dart'; @@ -120,112 +121,6 @@ int? _mfaUltimoPasso; int _passo(DateTime t) => t.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; -/// Backend do app com o ENVIO de visitas (sync, legado, diferido, revogação do -/// token de envio) "fora do ar" enquanto [offline]: falha recuperável, como a -/// queda de rede. Login, lista da microárea e logout seguem o servidor real — -/// é o que permite a A registrar e sair com a visita pendente, e a B entrar. -/// Não é a rede do aparelho caindo: só os caminhos de envio de visita. -class _EnvioDeVisitasForaDoAr implements AcsBackend { - _EnvioDeVisitasForaDoAr(this._inner); - - final AcsBackend _inner; - bool offline = true; - int recusas = 0; - - Future _rede(Future Function() call) { - if (offline) { - recusas++; - return Future.error(const BackendFailure('Sem conexão (simulada no e2e).')); - } - return call(); - } - - @override - AuthSession? get session => _inner.session; - @override - bool get isAuthenticated => _inner.isAuthenticated; - @override - void Function()? get onSessionExpired => _inner.onSessionExpired; - @override - set onSessionExpired(void Function()? value) => _inner.onSessionExpired = value; - @override - void Function(String orderId)? get onDeviceWipeRequired => _inner.onDeviceWipeRequired; - @override - set onDeviceWipeRequired(void Function(String orderId)? value) => _inner.onDeviceWipeRequired = value; - @override - Future pendingDeviceWipe(String deviceId) => _inner.pendingDeviceWipe(deviceId); - @override - Future confirmDeviceWipe(String deviceId, String orderId) => _inner.confirmDeviceWipe(deviceId, orderId); - @override - Future forgetLocalSession() => _inner.forgetLocalSession(); - @override - Future get hasStoredSession => _inner.hasStoredSession; - @override - Future unlockStoredSession({required String reason}) => _inner.unlockStoredSession(reason: reason); - @override - Future resumeSession() => _inner.resumeSession(); - @override - Future logout() => _inner.logout(); - @override - Future login({required String matricula, required String senha, String? totpCode}) => - _inner.login(matricula: matricula, senha: senha, totpCode: totpCode); - @override - Future beginTotpEnrollment({required String matricula, required String senha}) => - _inner.beginTotpEnrollment(matricula: matricula, senha: senha); - @override - Future confirmTotpEnrollment({required String matricula, required String senha, required String code}) => - _inner.confirmTotpEnrollment(matricula: matricula, senha: senha, code: code); - @override - Future developmentLogin({required String role}) => _inner.developmentLogin(role: role); - @override - Future acknowledge({required String alertId}) => _inner.acknowledge(alertId: alertId); - @override - Future> openAlerts() => _inner.openAlerts(); - @override - Future resolveAlert({required String alertId}) => - _inner.resolveAlert(alertId: alertId); - @override - 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 - Future> syncDeferredVisits({ - required String uploadToken, - required String deviceId, - required List visits, - }) => - _rede(() => _inner.syncDeferredVisits(uploadToken: uploadToken, deviceId: deviceId, visits: visits)); - @override - Future revokeUploadToken(String uploadToken) => _rede(() => _inner.revokeUploadToken(uploadToken)); - @override - Future> pullVisits({required DateTime since}) => _inner.pullVisits(since: since); - @override - Future> listPatients() => _inner.listPatients(); - @override - Future ubsContact() => _inner.ubsContact(); - @override - Future generateInvite({required String patientId}) => - _inner.generateInvite(patientId: patientId); - @override - Future sendNotice({required String title, required String message, required bool chronicOnly}) => - _inner.sendNotice(title: title, message: message, chronicOnly: chronicOnly); - @override - void close() => _inner.close(); -} - /// Espera o JWT vencer de verdade (15 min) em vez de forçar a renovação. const _esperarJwt = bool.fromEnvironment('E2E_ESPERAR_JWT'); @@ -402,7 +297,7 @@ void main() { final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); final real = _clienteComKeystore(ca); addTearDown(real.close); - final backend = _EnvioDeVisitasForaDoAr(real); + final backend = EnvioDeVisitasForaDoAr(real); final cred = await acsCredentialFromRelay(); final main = e2ePatient('main'); @@ -674,7 +569,7 @@ void main() { uploadTokens: uploadTokens, ); addTearDown(real.close); - final backend = _EnvioDeVisitasForaDoAr(real); + final backend = EnvioDeVisitasForaDoAr(real); // 1) Banco v6 de uma versão anterior do app, com uma visita SEM dono, no // arquivo e com a chave que o app usa (sinalacs_acs.db + Keystore). @@ -815,13 +710,21 @@ 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. + // Desde a #81 (CRIPTO-APP-CACHE, D6) ele também fecha e apaga o arquivo + // e troca a chave: a conexão `db` acima fica fechada, e a contagem é + // feita num banco reaberto, que tem de nascer vazio com chave nova. + final chaveAntes = await chaves.readOrCreate(); 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); expect(find.text('Os dados deste aparelho foram apagados.'), findsOneWidget); + expect(db.isOpen, isFalse, reason: 'o wipe fecha a conexão antes de apagar o arquivo'); + expect(await chaves.readOrCreate() == chaveAntes, isFalse, reason: 'o wipe troca a chave'); // sem imprimir a chave + final reaberto = await storage.database.open(); for (final tabela in ['offline_visits', 'sync_cursor', 'micro_area_cache', 'micro_area_cache_meta']) { - expect(await linhas(tabela), 0, reason: '$tabela depois do wipe'); + final n = (await reaberto.rawQuery('SELECT COUNT(*) AS n FROM $tabela')).single['n']! as int; + expect(n, 0, reason: '$tabela depois do wipe'); } expect(await uploadTokens.owners(), isEmpty); expect(await uploadTokens.read(idB), isNull, reason: 'o wipe apaga o token de envio de B'); diff --git a/apps/acs/integration_test/remote_wipe_e2e.dart b/apps/acs/integration_test/remote_wipe_e2e.dart new file mode 100644 index 0000000..9172c10 --- /dev/null +++ b/apps/acs/integration_test/remote_wipe_e2e.dart @@ -0,0 +1,287 @@ +/// Limpeza remota do aparelho (#81) no emulador, contra o BANCO DE TESTE: o +/// aparelho é limpo com uma visita NÃO enviada e o painel aberto. +/// +/// Montagem de produção (`main.dart`) no que a prova mede: o id da instalação +/// (`SecureStorageDeviceIdStore`), a chave do SQLCipher +/// (`SecureStorageDatabaseKeyStore`), o banco real `sinalacs_acs.db`, os tokens +/// de envio (`SecureStorageUploadTokenStore`) e o diário +/// (`SharedPreferencesWipeJournal`). O refresh token fica no +/// `SecureStorageSessionTokenStore` (desbloqueio `notRequired`), como nos +/// outros e2e do ACS: o `BiometricPrompt` não é o que está em prova aqui. +/// +/// Roteiro: +/// 1. o ACS entra pela tela; +/// 2. registra uma visita com o ENVIO de visitas fora do ar (decorator +/// [EnvioDeVisitasForaDoAr]) e fica no formulário com o painel aberto — +/// sem "Sair": o envio diferido do logout subiria a visita; +/// 3. por um `Client` próprio, o coordenador entra (relé `/coordenador`, +/// ativação da MFA e TOTP) e pede a limpeza (`stolen`); +/// 4. segundo plano e volta: a retomada pergunta `pendingWipe` e o aparelho +/// é limpo, até a tela `device_wiped`; +/// 5. no aparelho: o banco não existe ou reabre vazio, a chave mudou, não há +/// token de envio e o id da instalação é outro; +/// 6. "Entrar de novo" com a mesma credencial abre o painel com o id novo. +/// +/// Só roda por `scripts/qa/acs_remote_wipe_e2e.sh` (sufixo `_e2e`: a +/// descoberta do `flutter test integration_test` não o encontra). Linhas +/// marcadoras para o script: +/// E2E_WIPE_PEDIDO order= device= +/// E2E_WIPE_OK visita= acs= novo= +/// +/// PRIVACIDADE: só fixtures sintéticas geradas por execução. A chave do banco, +/// senhas e o segredo TOTP nunca são impressos. +@Timeout(Duration(minutes: 6)) +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/network/backend_config.dart'; +import 'package:sinalacs_acs/core/security/biometric_gate.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_acs/core/security/secure_session_token_store.dart'; +import 'package:sinalacs_acs/core/security/secure_upload_token_store.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory_factory.dart'; +import 'package:sinalacs_acs/core/services/remote_wipe.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' as api; + +import 'support/e2e_acs.dart'; +import 'support/envio_fora_do_ar.dart'; +import 'support/totp.dart'; + +class _SemBiometria implements BiometricGate { + @override + Future get isAvailable async => false; + @override + Future authenticate({required String reason}) async => UnlockResult.unavailable; +} + +Future _ate(WidgetTester tester, bool Function() feito, {required Duration teto, required String oQue}) async { + final fim = DateTime.now().add(teto); + while (!feito()) { + if (DateTime.now().isAfter(fim)) fail('$oQue: não aconteceu em ${teto.inSeconds} s'); + await tester.pump(const Duration(milliseconds: 250)); + } +} + +Future _ateNoDisco(WidgetTester tester, Future Function() feito, + {required Duration teto, required String oQue}) async { + final fim = DateTime.now().add(teto); + while (!await feito()) { + if (DateTime.now().isAfter(fim)) fail('$oQue: não aconteceu em ${teto.inSeconds} s'); + await tester.pump(const Duration(milliseconds: 300)); + } +} + +int _passo(DateTime t) => t.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + +/// Coordenador pela rede real, fora do app: ativa a MFA com o código de +/// ativação de uso único e entra com o TOTP do passo SEGUINTE (o servidor +/// barra o reuso do passo já gasto na confirmação). +Future _tokenDoCoordenador(api.Client cliente) async { + final cred = await coordinatorCredentialFromRelay(); + final inicio = await cliente.auth.beginStaffTotpEnrollment( + matricula: cred.matricula, + password: cred.senha, + activationCode: cred.activationCode, + ); + final segredo = base32Decode(inicio.secretBase32); + final confirmacao = DateTime.now(); + await cliente.auth.confirmStaffTotpEnrollment( + matricula: cred.matricula, + password: cred.senha, + activationCode: cred.activationCode, + code: totpCode(segredo, confirmacao), + ); + while (_passo(DateTime.now()) <= _passo(confirmacao)) { + await Future.delayed(const Duration(milliseconds: 500)); + } + final login = await cliente.auth.loginStaff( + matricula: cred.matricula, + password: cred.senha, + totpCode: totpCode(segredo, DateTime.now()), + ); + return login.accessToken; +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('limpeza remota com o painel aberto e uma visita não enviada: banco, chave, tokens e id saem', + (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + const host = String.fromEnvironment('SINALACS_HOST', defaultValue: 'https://10.0.2.2/'); + final cred = await acsCredentialFromRelay(); + final main = e2ePatient('main'); + + // 0) Montagem de produção, a partir de um aparelho sem restos de outras + // execuções: sessão, tokens de envio, diário e banco. + final deviceIds = SecureStorageDeviceIdStore(); + final uploadTokens = SecureStorageUploadTokenStore(); + final chaves = SecureStorageDatabaseKeyStore(); + final diario = SharedPreferencesWipeJournal(); + final sessao = SecureStorageSessionTokenStore(); + await sessao.clear(); + await uploadTokens.clearAll(); + await diario.clear(); + const arquivo = 'sinalacs_acs.db'; + await EncryptedLocalDatabase.deleteDatabaseFile(arquivo); + final caminho = await EncryptedLocalDatabase.pathFor(arquivo); + + final real = BackendClient( + trustedCaBytes: ca, + tokenStore: sessao, + deviceIds: deviceIds, + uploadTokens: uploadTokens, + ); + addTearDown(real.close); + final backend = EnvioDeVisitasForaDoAr(real); + final storage = SqlCipherVisitStorage(keyStore: chaves); + + await tester.pumpWidget(SinalAcsApp( + backend: backend, + biometricGate: _SemBiometria(), + syncInterval: const Duration(hours: 2), + visitStorage: storage, + microAreaDirectory: buildMicroAreaDirectory(backend: backend), + uploadTokens: uploadTokens, + deviceIds: deviceIds, + wipeJournal: diario, + )); + + Future entrar() async { + await _ate(tester, () => find.byKey(const Key('matricula_field')).evaluate().isNotEmpty, + teto: const Duration(seconds: 30), oQue: 'tela de login'); + await tester.enterText(find.byKey(const Key('matricula_field')), cred.matricula); + await tester.enterText(find.byKey(const Key('senha_field')), cred.senha); + final entrarBotao = find.byKey(const Key('login_button')); + await tester.ensureVisible(entrarBotao); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(entrarBotao); + await _ate( + tester, + () => entrarBotao.evaluate().isEmpty || find.byKey(const Key('login_error')).evaluate().isNotEmpty, + teto: const Duration(seconds: 30), + oQue: 'login', + ); + expect(find.byKey(const Key('login_error')), findsNothing); + } + + // 1) O ACS entra pela tela. O login envia o id da instalação: refresh token + // e token de envio ficam amarrados a ele no servidor. + await entrar(); + final acsId = real.session!.userId; + final idAntigo = await deviceIds.readOrCreate(); + expect(await uploadTokens.read(acsId), isNotNull, reason: 'o login com deviceId deixa o token de envio'); + final chaveAntiga = await chaves.readOrCreate(); + + // 2) Uma visita com o envio fora do ar: fica pendente, só no aparelho. + await tester.tap(find.text('Visita')); + await _ate(tester, () => find.byKey(Key('patient_${main.id}')).evaluate().isNotEmpty, + teto: const Duration(seconds: 30), oQue: 'paciente da microárea no seletor'); + await tester.tap(find.byKey(Key('patient_${main.id}'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('arrival_confirmation'))); + await tester.pump(); + final salvar = find.byKey(const Key('save_visit')); + await Scrollable.ensureVisible(tester.element(salvar), alignment: 0.5); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(salvar); + await _ate(tester, () => find.text('Pendentes de sincronização: 1').evaluate().isNotEmpty, + teto: const Duration(seconds: 20), oQue: 'visita pendente'); + final noDisco = await storage.forOwner(acsId).load(); + expect(noDisco, hasLength(1), reason: 'a visita está no disco, sob o dono'); + final visita = noDisco.single.localId; + // O ACS segue no formulário, com o painel aberto (Review Focus 1). + await tester.enterText(find.widgetWithText(TextField, 'Observações de campo'), 'e2e limpeza remota'); + await tester.pump(); + expect(backend.recusas, 0, reason: 'nada tentou subir ainda; a recusa abaixo é a prova do envio barrado'); + final sincronizar = find.byKey(const Key('sync_visits')); + await Scrollable.ensureVisible(tester.element(sincronizar), alignment: 0.5); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(sincronizar); + await _ate(tester, () => backend.recusas > 0, teto: const Duration(seconds: 10), oQue: 'envio tentado e barrado'); + expect(find.text('Pendentes de sincronização: 1'), findsOneWidget, reason: 'a visita continua pendente'); + + // 3) O coordenador pede a limpeza pela rede real (fora do app). + final cliente = api.Client(host, securityContext: SecurityContext()..setTrustedCertificatesBytes(ca)) + ..connectivityMonitor = null; + addTearDown(cliente.close); + final tokenCoordenador = await _tokenDoCoordenador(cliente); + final pedido = await cliente.admin.orderDeviceWipe( + accessToken: tokenCoordenador, + acsId: acsId, + deviceId: idAntigo, + reason: api.DeviceWipeReason.stolen, + ); + expect(pedido.alreadyOrdered, isFalse); + // ignore: avoid_print + print('E2E_WIPE_PEDIDO order=${pedido.orderId} device=$idAntigo'); + expect(find.byKey(const Key('device_wiped')), findsNothing, reason: 'nada muda antes de um gatilho'); + + // 4) Segundo plano e volta (a sequência completa: o campo de texto aberto + // escuta o ciclo e recusa pular estados). A retomada pergunta o pedido. + for (final estado in [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + AppLifecycleState.hidden, + AppLifecycleState.inactive, + AppLifecycleState.resumed, + ]) { + tester.binding.handleAppLifecycleStateChanged(estado); + } + await _ate(tester, () => find.byKey(const Key('device_wiped')).evaluate().isNotEmpty, + teto: const Duration(seconds: 30), oQue: 'tela device_wiped'); + expect(find.byKey(const Key('save_visit')), findsNothing, reason: 'o painel saiu'); + expect(real.session, isNull, reason: 'a limpeza esquece a sessão local'); + // A confirmação chega ao servidor e só então o diário é apagado (D7). + await _ateNoDisco(tester, () async => await diario.read() == null, + teto: const Duration(seconds: 30), oQue: 'confirmação entregue e diário apagado'); + + // 5) No aparelho. Antes de qualquer abertura: o arquivo sumiu? + final arquivoExistia = File(caminho).existsSync(); + expect(await uploadTokens.owners(), isEmpty, reason: 'nenhum token de envio sobra'); + expect(await uploadTokens.read(acsId), isNull); + final idNovo = await deviceIds.readOrCreate(); + expect(idNovo, isNot(idAntigo), reason: 'a instalação ganha outro id'); + final chaveNova = await chaves.readOrCreate(); + expect(chaveNova == chaveAntiga, isFalse, reason: 'a chave do SQLCipher foi trocada'); // sem imprimir a chave + final db = await storage.database.open(); + for (final tabela in ['offline_visits', 'sync_cursor', 'micro_area_cache', 'micro_area_cache_meta']) { + final n = (await db.rawQuery('SELECT COUNT(*) AS n FROM $tabela')).single['n']! as int; + expect(n, 0, reason: '$tabela depois da limpeza (arquivo existia: $arquivoExistia)'); + } + // ignore: avoid_print + print('E2E_WIPE_APARELHO arquivo_existia=$arquivoExistia chave_trocada=true tokens_de_envio=0'); + + // 6) "Entrar de novo" com a mesma credencial: o id novo não tem pedido. + // O envio volta ao ar; se a visita tivesse sobrado, subiria agora e o + // script a acharia em `visits`. + backend.offline = false; + final deNovo = find.byKey(const Key('device_wiped_login')); + await tester.ensureVisible(deNovo); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(deNovo); + await entrar(); + expect(real.session!.userId, acsId); + expect(await deviceIds.readOrCreate(), idNovo); + await tester.tap(find.text('Visita')); + await _ate(tester, () => find.text('Pendentes de sincronização: 0').evaluate().isNotEmpty, + teto: const Duration(seconds: 30), oQue: 'painel novo sem pendência'); + expect(await storage.forOwner(acsId).load(), isEmpty, reason: 'a visita não voltou'); + // ignore: avoid_print + print('E2E_WIPE_OK visita=$visita acs=$acsId novo=$idNovo'); + + // Desmonta o app aqui: o feed e os timers do painel ainda estão ativos. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 1)); + }); +} diff --git a/apps/acs/integration_test/support/e2e_acs.dart b/apps/acs/integration_test/support/e2e_acs.dart index 1f5d1f1..58af94c 100644 --- a/apps/acs/integration_test/support/e2e_acs.dart +++ b/apps/acs/integration_test/support/e2e_acs.dart @@ -86,3 +86,21 @@ Future<({String matricula, String senha})> _buscarCredencialDoSegundoAcs() async final json = jsonDecode(await _get('/acs-b')) as Map; return (matricula: json['matricula'] as String, senha: json['senha'] as String); } + +/// Matrícula, senha e código de ativação da MFA do COORDENADOR da fixture +/// (bloco `coordinator`, mesma UBS do ACS), servidos pelo relé em +/// `/coordenador`, também uma única vez. O código de ativação é de uso único: +/// o login do coordenador ativa a MFA com ele (#48). +Future<({String matricula, String senha, String activationCode})> coordinatorCredentialFromRelay() => + _coordinatorCredential ??= _buscarCredencialDoCoordenador(); + +Future<({String matricula, String senha, String activationCode})>? _coordinatorCredential; + +Future<({String matricula, String senha, String activationCode})> _buscarCredencialDoCoordenador() async { + final json = jsonDecode(await _get('/coordenador')) as Map; + return ( + matricula: json['matricula'] as String, + senha: json['senha'] as String, + activationCode: json['activationCode'] as String, + ); +} diff --git a/apps/acs/integration_test/support/envio_fora_do_ar.dart b/apps/acs/integration_test/support/envio_fora_do_ar.dart new file mode 100644 index 0000000..51640e0 --- /dev/null +++ b/apps/acs/integration_test/support/envio_fora_do_ar.dart @@ -0,0 +1,110 @@ +import 'package:sinalacs_acs/core/network/auth_session.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' as api; + +/// Backend do app com o ENVIO de visitas (sync, legado, diferido, revogação do +/// token de envio) "fora do ar" enquanto [offline]: falha recuperável, como a +/// queda de rede. Login, lista da microárea e logout seguem o servidor real — +/// é o que permite a A registrar e sair com a visita pendente, e a B entrar. +/// Não é a rede do aparelho caindo: só os caminhos de envio de visita. +class EnvioDeVisitasForaDoAr implements AcsBackend { + EnvioDeVisitasForaDoAr(this._inner); + + final AcsBackend _inner; + bool offline = true; + int recusas = 0; + + Future _rede(Future Function() call) { + if (offline) { + recusas++; + return Future.error(const BackendFailure('Sem conexão (simulada no e2e).')); + } + return call(); + } + + @override + AuthSession? get session => _inner.session; + @override + bool get isAuthenticated => _inner.isAuthenticated; + @override + void Function()? get onSessionExpired => _inner.onSessionExpired; + @override + set onSessionExpired(void Function()? value) => _inner.onSessionExpired = value; + @override + void Function(String orderId)? get onDeviceWipeRequired => _inner.onDeviceWipeRequired; + @override + set onDeviceWipeRequired(void Function(String orderId)? value) => _inner.onDeviceWipeRequired = value; + @override + Future pendingDeviceWipe(String deviceId) => _inner.pendingDeviceWipe(deviceId); + @override + Future confirmDeviceWipe(String deviceId, String orderId) => _inner.confirmDeviceWipe(deviceId, orderId); + @override + Future forgetLocalSession() => _inner.forgetLocalSession(); + @override + Future get hasStoredSession => _inner.hasStoredSession; + @override + Future unlockStoredSession({required String reason}) => _inner.unlockStoredSession(reason: reason); + @override + Future resumeSession() => _inner.resumeSession(); + @override + Future logout() => _inner.logout(); + @override + Future login({required String matricula, required String senha, String? totpCode}) => + _inner.login(matricula: matricula, senha: senha, totpCode: totpCode); + @override + Future beginTotpEnrollment({required String matricula, required String senha}) => + _inner.beginTotpEnrollment(matricula: matricula, senha: senha); + @override + Future confirmTotpEnrollment({required String matricula, required String senha, required String code}) => + _inner.confirmTotpEnrollment(matricula: matricula, senha: senha, code: code); + @override + Future developmentLogin({required String role}) => _inner.developmentLogin(role: role); + @override + Future acknowledge({required String alertId}) => _inner.acknowledge(alertId: alertId); + @override + Future> openAlerts() => _inner.openAlerts(); + @override + Future resolveAlert({required String alertId}) => + _inner.resolveAlert(alertId: alertId); + @override + 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 + Future> syncDeferredVisits({ + required String uploadToken, + required String deviceId, + required List visits, + }) => + _rede(() => _inner.syncDeferredVisits(uploadToken: uploadToken, deviceId: deviceId, visits: visits)); + @override + Future revokeUploadToken(String uploadToken) => _rede(() => _inner.revokeUploadToken(uploadToken)); + @override + Future> pullVisits({required DateTime since}) => _inner.pullVisits(since: since); + @override + Future> listPatients() => _inner.listPatients(); + @override + Future ubsContact() => _inner.ubsContact(); + @override + Future generateInvite({required String patientId}) => + _inner.generateInvite(patientId: patientId); + @override + Future sendNotice({required String title, required String message, required bool chronicOnly}) => + _inner.sendNotice(title: title, message: message, chronicOnly: chronicOnly); + @override + void close() => _inner.close(); +} diff --git a/docs/superpowers/plans/2026-10-17-issue-81-e2e.log b/docs/superpowers/plans/2026-10-17-issue-81-e2e.log new file mode 100644 index 0000000..bbf5925 --- /dev/null +++ b/docs/superpowers/plans/2026-10-17-issue-81-e2e.log @@ -0,0 +1,106 @@ +== stack de e2e (banco de teste) +aviso: o backend em execução (sinalacs-serverpod) será substituído pelo de e2e; ao final, rode `docker compose up -d` para voltar à stack de desenvolvimento. +NOTICE: database "sinalacs_e2e" does not exist, skipping +Fixtures de e2e gravadas (5 pacientes, 2 ACS, 1 admin, 1 coordenador) em ../../.e2e/fixtures.json. +== limpeza remota no emulador +Resolving dependencies... +Downloading packages... + async 2.11.0 (2.13.1 available) + boolean_selector 2.1.1 (2.1.2 available) + clock 1.1.2 (1.1.3 available) + connectivity_plus 7.3.1 (7.3.2 available) + dbus 0.7.12 (0.8.0 available) + ffi 2.1.3 (2.2.0 available) + flutter_lints 3.0.2 (6.0.0 available) + flutter_secure_storage 9.2.4 (11.2.0 available) + flutter_secure_storage_linux 1.2.3 (3.0.3 available) + flutter_secure_storage_macos 3.1.3 (4.0.0 available) + flutter_secure_storage_platform_interface 1.1.2 (2.1.1 available) + flutter_secure_storage_web 1.2.1 (2.1.1 available) + flutter_secure_storage_windows 3.1.2 (4.2.2 available) + geolocator 12.0.0 (14.1.1 available) + geolocator_android 4.6.2 (5.1.1+1 available) + geolocator_apple 2.3.14 (2.3.15 available) + geolocator_platform_interface 4.3.0 (4.4.0 available) + google_maps_flutter 2.18.0 (2.19.0 available) + google_maps_flutter_android 2.19.13 (2.21.0 available) + google_maps_flutter_ios 2.18.6 (2.18.7 available) + google_maps_flutter_platform_interface 2.16.1 (2.17.0 available) + google_maps_flutter_web 0.6.3+1 (0.6.4+1 available) + html 0.15.6 (0.15.7 available) + jni 1.0.3 (1.1.0 available) + jni_flutter 1.0.3 (1.0.4+1 available) + js 0.6.7 (0.7.2 available) + lints 3.0.0 (6.1.0 available) + local_auth_android 2.2.0 (2.3.0 available) + matcher 0.12.19 (0.12.20 available) + material_color_utilities 0.13.0 (0.13.1 available) + meta 1.18.0 (1.19.0 available) + mqtt_client 10.5.1 (10.13.0 available) + nm 0.5.0 (0.6.0 available) + path_provider_foundation 2.5.1 (2.6.0 available) + petitparser 6.0.2 (7.1.0 available) + platform 3.1.6 (3.2.0 available) + qr 3.0.2 (4.0.0 available) + serverpod_auth_core_client 3.4.13 (4.0.4 available) + serverpod_auth_idp_client 3.4.13 (4.0.4 available) + serverpod_client 3.4.13 (4.0.4 available) + serverpod_serialization 3.4.13 (4.0.4 available) + shared_preferences 2.5.5 (2.5.6 available) + shared_preferences_android 2.4.28 (2.5.0 available) + source_span 1.10.0 (1.10.2 available) + sqflite 2.4.1 (2.4.4+2 available) + sqflite_android 2.4.0 (2.4.4+1 available) + sqflite_common 2.5.4+6 (2.5.14 available) + sqflite_common_ffi 2.3.4+4 (2.4.4 available) + sqflite_darwin 2.4.1+1 (2.4.4+1 available) + sqflite_platform_interface 2.4.0 (2.4.2 available) + sqflite_sqlcipher 3.2.0 (3.4.1 available) + sqlite3 2.9.4 (3.7.1 available) + stack_trace 1.12.1 (1.12.2 available) + string_scanner 1.2.0 (1.4.1 available) + synchronized 3.3.0+3 (3.5.0 available) + term_glyph 1.2.1 (1.2.2 available) + test_api 0.7.11 (0.7.15 available) + universal_html 2.2.4 (2.3.0 available) + universal_io 2.2.2 (2.3.1 available) + url_launcher 6.3.2 (6.3.3 available) + vector_math 2.2.0 (2.4.3 available) + vm_service 14.2.5 (15.3.0 available) + win32 5.13.0 (6.4.0 available) + xml 6.5.0 (7.1.0 available) +Got dependencies! +64 packages have newer versions incompatible with dependency constraints. +Try `flutter pub outdated` for more information. +00:00 +0: loading /home/rock/Documents/Dev/APPs/SinalACS/apps/acs/integration_test/remote_wipe_e2e.dart +Running Gradle task 'assembleDebug'... +WARNING: A restricted method in java.lang.System has been called +WARNING: java.lang.System::load has been called by net.rubygrapefruit.platform.internal.NativeLibraryLoader in an unnamed module (file:/home/rock/.gradle/wrapper/dists/gradle-9.1.0-all/7wzd0jkjit61aq2p43wpjgij9/gradle-9.1.0/lib/native-platform-0.22-milestone-28.jar) +WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module +WARNING: Restricted methods will be blocked in a future release unless native access is enabled + +Running Gradle task 'assembleDebug'... 13.3s +✓ Built build/app/outputs/flutter-apk/app-debug.apk +Installing build/app/outputs/flutter-apk/app-debug.apk... 618ms +00:00 +0: limpeza remota com o painel aberto e uma visita não enviada: banco, chave, tokens e id saem +E2E_WIPE_PEDIDO order=d5c369ff-b4c1-4a87-906a-b4cf249d8298 device=b3f82da9-d40b-4568-8542-539fda989bca +E2E_WIPE_APARELHO arquivo_existia=false chave_trocada=true tokens_de_envio=0 +E2E_WIPE_OK visita=68f4decd-f6dd-48f2-93d5-6931e3c62c70 acs=03ce7c38-a1d3-4f2b-bb03-0d37799fefa6 novo=2d86f5e1-525f-414e-912f-7f138a51dd44 +00:34 +1: (tearDownAll) +00:34 +1: All tests passed! +== limpeza remota no banco de teste (pedido d5c369ff-b4c1-4a87-906a-b4cf249d8298) + pedido do aparelho: id|motivo|alvo|quem pediu: d5c369ff-b4c1-4a87-906a-b4cf249d8298|stolen|03ce7c38-a1d3-4f2b-bb03-0d37799fefa6|63fecb1f-c3db-4d37-972b-e93c90ec47d0 + pedido entregue e confirmado (deliveredAt e confirmedAt): true + o id antigo tinha refresh token e token de envio: true + refresh tokens vivos do id antigo: 0 + tokens de envio vivos do id antigo: 0 + o login de novo usou o id novo (refresh token vivo): true + trilha wipe_ordered (admin_device, pelo coordenador): 1 + trilha wipe_delivered (acs_device): 1 + trilha wipe_confirmed (acs_device): 1 + a visita não enviada NÃO está em visits: 0 + nenhuma visita do ACS no servidor: 0 +== cadeia de auditoria no banco de teste + OK — 18 linha(s) verificada(s), cadeia íntegra. +E2E de limpeza remota: OK +A stack de desenvolvimento foi substituída pela de e2e: rode `docker compose up -d` para voltá-la. diff --git a/scripts/qa/acs_remote_wipe_e2e.sh b/scripts/qa/acs_remote_wipe_e2e.sh new file mode 100755 index 0000000..792099e --- /dev/null +++ b/scripts/qa/acs_remote_wipe_e2e.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# +# Limpeza remota do aparelho do ACS (issue #81) no emulador-5554, contra o +# BANCO DE TESTE: o aparelho é limpo com uma visita NÃO enviada e o painel +# aberto. +# +# ./scripts/qa/acs_remote_wipe_e2e.sh +# DEVICE=0087014315 ./scripts/qa/acs_remote_wipe_e2e.sh # aparelho físico +# +# O teste (apps/acs/integration_test/remote_wipe_e2e.dart) entra como o ACS da +# fixture, registra uma visita com o envio de visitas fora do ar (decorator de +# `AcsBackend`: só os caminhos de envio; login e lista seguem o servidor real) e +# fica no formulário. Por um `Client` próprio, o coordenador da fixture (relé +# `/coordenador`, ativação da MFA e TOTP) pede a limpeza do aparelho. A volta do +# segundo plano dispara a consulta e o aparelho é limpo: banco, chave do +# SQLCipher, tokens de envio e id da instalação. "Entrar de novo" abre o painel +# com o id novo. Depois, este script confere no banco o pedido entregue e +# confirmado, os tokens do id antigo revogados, a trilha dos três eventos, a +# visita que NUNCA chegou a `visits` e a cadeia de auditoria. +# +# ATENÇÃO: substitui a stack de desenvolvimento pela de e2e; o banco de e2e e o +# manifesto são apagados ao final. +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/lib_rele.sh" + +for arg in "$@"; do + case "$arg" in + *) echo "argumento desconhecido: $arg" >&2; exit 2 ;; + esac +done + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin:$HOME/.pub-cache/bin" + +dev="${DEVICE:-emulator-5554}" +adb -s "$dev" get-state >/dev/null 2>&1 || { echo "aparelho $dev não encontrado (adb devices)"; exit 4; } + +relay_pid="" +saida_teste="$(mktemp)" +cleanup() { + rm -f "$saida_teste" + [[ -n "$relay_pid" ]] && kill "$relay_pid" 2>/dev/null || true + rm -f .e2e/fixtures.json # primeiro: tem as senhas sintéticas, mesmo se o down falhar + ./scripts/qa/e2e_stack.sh down >/dev/null 2>&1 \ + || echo 'aviso: e2e_stack.sh down falhou; o banco sinalacs_e2e pode ter sobrado' >&2 + echo 'A stack de desenvolvimento foi substituída pela de e2e: rode `docker compose up -d` para voltá-la.' +} +trap cleanup EXIT + +if porta_ocupada 8765; then + echo 'erro: a porta 8765 já está ocupada (relé antigo?). Encerre-o: pkill -f scripts/qa/otp_relay.py' >&2 + exit 1 +fi + +echo "== stack de e2e (banco de teste)" +./scripts/qa/e2e_stack.sh up +./scripts/qa/e2e_stack.sh seed +./scripts/dev/sync_dev_ca.sh >/dev/null 2>&1 || true +set -a; source .env; set +a +adb -s "$dev" reverse tcp:8443 tcp:443 >/dev/null +adb -s "$dev" reverse tcp:8883 tcp:8883 >/dev/null +adb -s "$dev" reverse tcp:8765 tcp:8765 >/dev/null + +E2E_FIXTURES_FILE="$repo_root/.e2e/fixtures.json" python3 scripts/qa/otp_relay.py >/dev/null 2>&1 & +relay_pid=$! +for _ in $(seq 1 20); do + curl -fs http://127.0.0.1:8765/now >/dev/null 2>&1 && break + sleep 0.25 +done +kill -0 "$relay_pid" 2>/dev/null || { echo 'erro: o relé não subiu' >&2; exit 1; } + +sql() { ./scripts/qa/e2e_stack.sh psql "$1"; } +fx() { python3 -c "import json,sys;d=json.load(open('.e2e/fixtures.json'));print(eval(sys.argv[1]))" "$1" || true; } +acs_id="$(fx "d['acs']['id']")" +coord_id="$(fx "d['coordinator']['id']")" +[[ -n "$acs_id" && -n "$coord_id" ]] || { echo 'erro: manifesto de fixtures incompleto' >&2; exit 1; } +# Sem os blocos de credencial: as senhas chegam pelo relé, nunca pelo argv. +fixtures="$(python3 -c "import json;d=json.load(open('.e2e/fixtures.json'));d.pop('acs',None);d.pop('acsB',None);d.pop('staff',None);d.pop('coordinator',None);print(json.dumps(d))")" + +echo "== limpeza remota no emulador" +( cd apps/acs && flutter pub get >/dev/null && flutter test integration_test/remote_wipe_e2e.dart -d "$dev" \ + --dart-define=SINALACS_HOST=https://localhost:8443/ \ + --dart-define=SINALACS_MQTT_HOST=localhost \ + --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" \ + --dart-define=E2E_FIXTURES="$fixtures" ) 2>&1 | tee "$saida_teste" +[[ "${PIPESTATUS[0]}" -eq 0 ]] || { echo 'erro: a limpeza remota no emulador falhou' >&2; exit 1; } +for marca in E2E_WIPE_PEDIDO E2E_WIPE_APARELHO E2E_WIPE_OK; do + grep -q "$marca" "$saida_teste" || { echo "erro: o teste terminou sem a linha $marca" >&2; exit 1; } +done + +pedido="$(grep -o 'E2E_WIPE_PEDIDO .*' "$saida_teste" | tail -1)" +ok="$(grep -o 'E2E_WIPE_OK .*' "$saida_teste" | tail -1)" +campo() { sed -n "s/.* $2=\([0-9a-fA-F-]*\).*/\1/p" <<<" $1"; } +ordem="$(campo "$pedido" order)"; aparelho="$(campo "$pedido" device)" +visita="$(campo "$ok" visita)"; novo="$(campo "$ok" novo)" +uuid='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' +[[ "$ordem" =~ $uuid && "$visita" =~ $uuid ]] || { echo 'erro: marcadores sem uuid de pedido ou de visita' >&2; exit 1; } +[[ "$aparelho" =~ $uuid && "$novo" =~ $uuid && "$aparelho" != "$novo" ]] \ + || { echo 'erro: id antigo e novo da instalação ausentes ou iguais' >&2; exit 1; } +[[ "$(campo "$ok" acs)" == "$acs_id" ]] || { echo 'erro: o ACS no aparelho não é o da fixture' >&2; exit 1; } + +echo "== limpeza remota no banco de teste (pedido $ordem)" +igual() { # + local v; v="$(sql "$3")" + echo " $1: $v" + [[ "$v" == "$2" ]] || { echo "erro: esperado '$2' ($1)" >&2; exit 1; } +} +igual 'pedido do aparelho: id|motivo|alvo|quem pediu' "$ordem|stolen|$acs_id|$coord_id" \ + "select id || '|' || reason || '|' || \"targetUserId\" || '|' || \"requestedBy\" from device_wipe_orders where \"deviceId\" = '$aparelho'" +igual 'pedido entregue e confirmado (deliveredAt e confirmedAt)' 'true' \ + "select (\"deliveredAt\" is not null and \"confirmedAt\" is not null)::text from device_wipe_orders where id = '$ordem'" +# Não vazio: o id antigo TINHA tokens (senão "zero vivos" não provaria nada). +igual 'o id antigo tinha refresh token e token de envio' 'true' \ + "select ((select count(*) from acs_refresh_tokens where \"deviceId\" = '$aparelho') > 0 and (select count(*) from acs_upload_tokens where \"deviceId\" = '$aparelho') > 0)::text" +igual 'refresh tokens vivos do id antigo' 0 \ + "select count(*) from acs_refresh_tokens where \"deviceId\" = '$aparelho' and \"revokedAt\" is null" +igual 'tokens de envio vivos do id antigo' 0 \ + "select count(*) from acs_upload_tokens where \"deviceId\" = '$aparelho' and \"revokedAt\" is null" +igual 'o login de novo usou o id novo (refresh token vivo)' 'true' \ + "select (count(*) > 0)::text from acs_refresh_tokens where \"userId\" = '$acs_id' and \"deviceId\" = '$novo' and \"revokedAt\" is null" +igual 'trilha wipe_ordered (admin_device, pelo coordenador)' 1 \ + "select count(*) from audit_logs where \"resourceId\" = '$ordem' and \"resourceType\" = 'admin_device' and result = 'wipe_ordered' and \"userId\" = '$coord_id'" +igual 'trilha wipe_delivered (acs_device)' 1 \ + "select count(*) from audit_logs where \"resourceId\" = '$ordem' and \"resourceType\" = 'acs_device' and result = 'wipe_delivered'" +igual 'trilha wipe_confirmed (acs_device)' 1 \ + "select count(*) from audit_logs where \"resourceId\" = '$ordem' and \"resourceType\" = 'acs_device' and result = 'wipe_confirmed'" +igual 'a visita não enviada NÃO está em visits' 0 \ + "select count(*) from visits where \"localId\" = '$visita'" +igual 'nenhuma visita do ACS no servidor' 0 \ + "select count(*) from visits where \"acsId\" = '$acs_id'" + +# Cadeia de hash da trilha (LGPD-RT03) íntegra depois do pedido, da entrega e da confirmação. +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 'E2E de limpeza remota: OK' diff --git a/scripts/qa/acs_remote_wipe_e2e_test.sh b/scripts/qa/acs_remote_wipe_e2e_test.sh new file mode 100755 index 0000000..3745a5c --- /dev/null +++ b/scripts/qa/acs_remote_wipe_e2e_test.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Confere que acs_remote_wipe_e2e.sh e o teste remote_wipe_e2e.dart provam a +# limpeza remota (#81) no aparelho real: montagem de produção do id, da chave, +# dos tokens de envio e do diário; visita deixada pendente pelo decorator sem +# "Sair" (o envio do logout a subiria); pedido pelo coordenador do relé; gatilho +# pela volta do segundo plano; e o script terminando nos asserts que só o banco +# responde, com o manifesto apagado antes do down — e que a descoberta do +# `flutter test` não alcança o teste (sufixo `_e2e`). Sem Docker e sem aparelho: +# lê o texto. +set -euo pipefail +cd "$(dirname "$0")/../.." +s=scripts/qa/acs_remote_wipe_e2e.sh +t=apps/acs/integration_test/remote_wipe_e2e.dart +falhas=0 +conferir() { if ! eval "$2"; then echo "FALHOU: $1" >&2; falhas=$((falhas+1)); fi; } + +conferir 'o script existe e é bash válido' "[[ -f $s ]] && bash -n $s" +conferir 'o teste tem sufixo _e2e (fora da descoberta)' "[[ -f $t ]]" +conferir 'DEVICE com padrão emulator-5554' "grep -qF 'dev=\"\${DEVICE:-emulator-5554}\"' $s" +conferir 'usa lib_rele.sh' "grep -qF 'lib_rele.sh' $s" +conferir 'sobe a stack de e2e e semeia' "grep -qF 'e2e_stack.sh up' $s && grep -qF 'e2e_stack.sh seed' $s" +conferir 'relé com E2E_FIXTURES_FILE' "grep -qF 'E2E_FIXTURES_FILE=' $s" +conferir 'adb reverse do RPC e do relé' "grep -qF 'reverse tcp:8443 tcp:443' $s && grep -qF 'reverse tcp:8765 tcp:8765' $s" +conferir 'recusa relé antigo na porta 8765' "grep -qF 'porta_ocupada 8765' $s" +conferir 'credenciais fora do argv (pop do coordinator)' "grep -qF \"d.pop('coordinator',None)\" $s" +conferir 'o manifesto sai antes do down' \ + "awk '/^cleanup\\(\\)/,/^}/' $s | grep -n -e 'rm -f .e2e/fixtures.json' -e 'e2e_stack.sh down' | head -1 | grep -qF 'fixtures.json'" + +# Montagem de produção no que a prova mede. +for loja in SecureStorageDeviceIdStore SecureStorageDatabaseKeyStore SecureStorageUploadTokenStore SharedPreferencesWipeJournal; do + conferir "o teste usa $loja" "grep -qF '$loja()' $t" +done +conferir 'o teste usa o arquivo real do app' "grep -qF \"'sinalacs_acs.db'\" $t" +conferir 'visita pendente pelo decorator de envio' "grep -qF 'EnvioDeVisitasForaDoAr(real)' $t" +conferir 'sem "Sair" antes da limpeza (o envio do logout subiria a visita)' "! grep -qF 'logout_button' $t" +conferir 'coordenador pelo relé' "grep -qF 'coordinatorCredentialFromRelay()' $t" +conferir 'ativação da MFA do coordenador' "grep -qF 'beginStaffTotpEnrollment' $t && grep -qF 'confirmStaffTotpEnrollment' $t" +conferir 'pedido com motivo stolen' "grep -qF 'orderDeviceWipe(' $t && grep -qF 'DeviceWipeReason.stolen' $t" +conferir 'gatilho pela volta do segundo plano' \ + "grep -qF 'AppLifecycleState.paused' $t && grep -qF 'AppLifecycleState.resumed' $t && grep -qF 'handleAppLifecycleStateChanged' $t" +conferir 'espera a tela device_wiped' "grep -qF \"Key('device_wiped')\" $t" +conferir 'entra de novo pela tela' "grep -qF \"Key('device_wiped_login')\" $t" +conferir 'confere o id novo diferente' "grep -qF 'isNot(idAntigo)' $t" +conferir 'confere os tokens de envio vazios' "grep -qF 'uploadTokens.owners(), isEmpty' $t" +conferir 'confere a chave trocada' "grep -qF 'chaveNova == chaveAntiga' $t" +conferir 'a chave nunca é impressa' "! grep -qE 'print\\(.*chave(Nova|Antiga)' $t" + +for marca in E2E_WIPE_PEDIDO E2E_WIPE_APARELHO E2E_WIPE_OK; do + conferir "o teste imprime $marca" "grep -qF \"print('$marca\" $t" + conferir "o script exige $marca" "grep -qF '$marca' $s" +done + +# Asserts que só o banco responde. +conferir 'assert: pedido com motivo stolen' "grep -qF 'from device_wipe_orders' $s && grep -qF '|stolen|' $s" +conferir 'assert: deliveredAt e confirmedAt' "grep -qF '\\\"deliveredAt\\\" is not null and \\\"confirmedAt\\\" is not null' $s" +conferir 'assert: refresh tokens do id antigo revogados' "grep -qF 'from acs_refresh_tokens where \\\"deviceId\\\" = '\"'\"'\$aparelho'\"'\"' and \\\"revokedAt\\\" is null' $s" +conferir 'assert: tokens de envio do id antigo revogados' "grep -qF 'from acs_upload_tokens where \\\"deviceId\\\" = '\"'\"'\$aparelho'\"'\"' and \\\"revokedAt\\\" is null' $s" +for evento in wipe_ordered wipe_delivered wipe_confirmed; do + conferir "assert: trilha $evento com o resourceId do pedido" "grep -qF \"result = '$evento'\" $s" +done +conferir 'assert: wipe_ordered pelo coordenador' "grep -qF 'and \\\"userId\\\" = '\"'\"'\$coord_id'\"'\"'' $s" +conferir 'assert: a visita não está em visits' "grep -qF 'from visits where \\\"localId\\\" = ' $s" +conferir 'assert: cadeia de auditoria' "grep -qF 'audit_chain_check' $s" +conferir 'linha final de sucesso' "grep -qF \"echo 'E2E de limpeza remota: OK'\" $s" + +if [[ $falhas -gt 0 ]]; then echo "$falhas conferência(s) falharam" >&2; exit 1; fi +echo OK From 5cabe73e083b4e76760998208c6d944d6efcb213 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 16:39:33 -0400 Subject: [PATCH 12/17] =?UTF-8?q?docs:=20registra=20a=20limpeza=20remota?= =?UTF-8?q?=20e=20a=20rota=C3=A7=C3=A3o=20da=20chave=20do=20cache=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 6 +++--- PROGRESS.md | 18 ++++++++++++++++++ apps/CLAUDE.md | 4 ++++ backend/CLAUDE.md | 2 ++ docs/auditoria-prd/2026-10-15-lacunas-prd.md | 8 ++++---- docs/telas-acs.md | 6 ++++++ docs/telas-admin.md | 6 ++++++ spec/lgpd_data_audit.md | 5 +++++ spec/lgpd_design.md | 5 +++-- 9 files changed, 51 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9763a25..18e5379 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) 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`). +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`), the remote device wipe ordered by the backoffice (#81: the coordinator of the UBS or the admin orders it from the ACS's "Aparelhos" dialog, the app wipes at startup, resume, network return or refresh, the SQLCipher key is rotated by event and never by calendar; the device's per-device MQTT client certificate is still missing, tracked by the continuation issue to be opened), 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`), 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`, `openForMicroArea` — a reserva por HTTP do ACS quando o MQTT cai, #80), `patients` (`listMicroArea`, `myData`, chronic conditions, `updateConsent`, `requestDataDeletion`, `requestDataCorrection`), `visits` (`sync`, `pull` e `resolveConflict` (#79) com a sessão do ACS; `syncLegacy`, que usa a sessão só como transporte das visitas legadas sem dono; `syncDeferred` e `revokeUploadToken`, autenticados pelo token de envio diferido do dono, não pelo JWT), `admin` (`indicators`, `microAreas`, `alerts` e `auditLogs` — leitura do backoffice escopada por papel/UBS desde #40; desde a #43 a gestão de contas: `acs`/`staff`, `createAcs`, `setAcsMicroArea`, `setAcsActive`, `resetAcsPassword`, `resetAcsMfa` e `resetStaffMfa`; desde a #44 `escalateAlert`, `alertsPulse` e `endMonitoringSession`; desde a #46 `scheduleNotice`, `cancelScheduledNotice`, `noticeHistory`, `termsVersions`, `draftTermsVersion`, `markTermsReviewed` e `publishTermsVersion` — detalhe em `backend/CLAUDE.md`), `health.check`. +Main RPC endpoints (`backend/sinalacs_server/lib/src/endpoints/`): `auth` (`loginInstitutional`, `requestOtp`, `verifyOtp`, `developmentLogin` — the last only with `ENABLE_DEV_LOGIN`), `onboarding`, `triage.evaluate`, `alerts` (`createRedAlert`, `acknowledge`, `resolve`, `statusFor`, `openForMicroArea` — a reserva por HTTP do ACS quando o MQTT cai, #80), `patients` (`listMicroArea`, `myData`, chronic conditions, `updateConsent`, `requestDataDeletion`, `requestDataCorrection`), `visits` (`sync`, `pull` e `resolveConflict` (#79) com a sessão do ACS; `syncLegacy`, que usa a sessão só como transporte das visitas legadas sem dono; `syncDeferred` e `revokeUploadToken`, autenticados pelo token de envio diferido do dono, não pelo JWT), `admin` (`indicators`, `microAreas`, `alerts` e `auditLogs` — leitura do backoffice escopada por papel/UBS desde #40; desde a #43 a gestão de contas: `acs`/`staff`, `createAcs`, `setAcsMicroArea`, `setAcsActive`, `resetAcsPassword`, `resetAcsMfa` e `resetStaffMfa`; desde a #44 `escalateAlert`, `alertsPulse` e `endMonitoringSession`; desde a #46 `scheduleNotice`, `cancelScheduledNotice`, `noticeHistory`, `termsVersions`, `draftTermsVersion`, `markTermsReviewed` e `publishTermsVersion`; desde a #81 `acsDevices` e `orderDeviceWipe` — detalhe em `backend/CLAUDE.md`), `devices` (`pendingWipe`, `confirmWipe` — #81, public by design, no token, answer only an `orderId` or nothing), `health.check`. `PROGRESS.md` holds milestone status and the open items with owners. `spec/validation_report.md` is stale in both directions (items it lists as open are closed, and its test counts are far below the real ones) — verify against the code before trusting it. @@ -23,7 +23,7 @@ Read these before making product/architecture decisions — when project docs co - [spec/stack.md](spec/stack.md) — stack/infra architecture decisions. - [spec/ui_design.md](spec/ui_design.md) — visual language and UX behavior. - [spec/lgpd_design.md](spec/lgpd_design.md) — privacy/LGPD design. -- [spec/lgpd_data_audit.md](spec/lgpd_data_audit.md) — field-by-field LGPD sensitivity classification for every persisted table (25 domain tables plus Serverpod's own; the count changes with every migration — re-measure it in the `definition.sql` of the latest `backend/sinalacs_server/migrations/*/`, and see L-17 of `spec/validation_report.md` for the drift this line has already accumulated). +- [spec/lgpd_data_audit.md](spec/lgpd_data_audit.md) — field-by-field LGPD sensitivity classification for every persisted table (26 domain tables plus Serverpod's own; the count changes with every migration — re-measure it in the `definition.sql` of the latest `backend/sinalacs_server/migrations/*/`, and see L-17 of `spec/validation_report.md` for the drift this line has already accumulated). - [spec/ux_accessibility_assessment.md](spec/ux_accessibility_assessment.md) — WCAG 2.2 AA audit (contrast, touch targets, semantics) for the ACS/patient/admin apps; read before touching any color used as text/icon, not just fill. - [spec/ux_ui_test_plan.md](spec/ux_ui_test_plan.md) — UX/UI test plan derived from `spec/ui_design.md` (visual/interaction behavior, complementary to the accessibility assessment). - [AGENTS.md](AGENTS.md) — full agent working rules (Portuguese), summarized below. diff --git a/PROGRESS.md b/PROGRESS.md index c6fedb6..4baa92a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1695,3 +1695,21 @@ Backlog priorizado na seção final do relatório; conferência por `python3 scr - O teto de 200 não pagina: numa microárea com mais de 200 abertos, os mais ANTIGOS de cada grupo ficam de fora (não confirmados primeiro; o corte nunca tira o pendente mais novo). - Um alerta concluído em outro aparelho do mesmo ACS some da resposta (o servidor não devolve `resolved`) mas o cartão local não muda, porque a fusão nunca remove: segue contando até sair do app. - Revisão final (2026-10-17), corrigido com teste que falhou antes: (1) com `autoReconnect`, a queda do broker DEPOIS da primeira conexão não chamava `onDisconnected` no `mqtt_client` 10.5.1 — o painel seguia "em linha" e a reserva nunca ligava; `MqttSecureClient` agora trata `onAutoReconnect` como queda e usa `disconnectOnNoResponsePeriod = 10` para a conexão meio aberta do 3G (`test/mqtt_drop_after_connect_test.dart`, broker falso em TCP real); (2) o teto de 200 cortava o pendente mais NOVO — agora cada grupo é lido do mais novo para trás (`acs_open_alerts_test.dart`); (3) ao armar, a reserva consulta na hora e zera o status anterior, em vez de esperar 30 s mostrando a hora de quando o MQTT funcionava (`alert_http_fallback_shell_test.dart`). + +## Limpeza remota do aparelho e rotação da chave (#81) — 2026-10-17 + +- Entregue: o coordenador da UBS ou o administrador ordena a limpeza de um aparelho do ACS pelo diálogo "Aparelhos" do cartão do ACS no backoffice (`admin.acsDevices`, `admin.orderDeviceWipe`; motivo Perdido/Roubado/Substituído). O pedido grava `device_wipe_orders` (migração `20261017171640323`) e revoga, na mesma transação, os tokens de todos os usuários daquele `deviceId`. O app ACS (`devices.pendingWipe`/`confirmWipe`, `RemoteWipeCoordinator`, `WipeJournal`) aplica na partida, na retomada, na volta da rede e quando a renovação/login/envio diferido recebe `DeviceWipeRequiredException`: desmonta o painel, descarta as filas em RAM sem gravar, apaga o banco SQLCipher, sidecars e `.recuperado`, troca a chave, apaga os tokens de envio e o `deviceId`, mostra `device_wiped` e confirma ao servidor. Aparelho limpo entra de novo com um `deviceId` novo. "Limpar este aparelho" (local) também passou a apagar o arquivo e trocar a chave (`VisitDatabase.destroy`). +- Decisões: D1 identidade do aparelho = `deviceId` da instalação, sem tabela de aparelhos (lista = `acs_refresh_tokens` ∪ `acs_upload_tokens`; índice único em `device_wipe_orders.deviceId`); D2 o wipe remoto ignora o bloqueio de visitas não enviadas (o diálogo do admin avisa; o local segue recusando); D3 com o aparelho online a entrega ocorre em até 15 min (JWT vivo não é revogado por chamada; a renovação seguinte recebe a exceção; partida, retomada e volta da rede perguntam na hora); D4 sem cancelamento; D5 revogação por aparelho, não por conta (cortar a conta é `setAcsActive(false)`, #43); D6 rotação da chave **por evento, não por calendário** (sem `PRAGMA rekey` periódico, que reescreveria o arquivo com visitas pendentes dentro; registrado em `spec/lgpd_design.md` §5.1); D7 diário gravado antes de apagar (`erasing`/`erased`, só `orderId` e `deviceId` antigo); D8 certificado de cliente por aparelho adiado para a issue de continuação (a abrir), com o certificado de dev `acs-area-12` ainda no APK de debug e o release recusando a chave privada no asset. INV-03: os alertas seguem `pending` no servidor e o escalonamento da #44 continua valendo. +- Prova: `./scripts/qa/acs_remote_wipe_e2e.sh` no emulador API 36 contra o banco de teste, 2026-10-17: ACS com uma visita não enviada e o painel aberto, pedido `stolen` do coordenador, ciclo de vida inactive→resumed, tela `device_wiped`, arquivo do banco ausente após a limpeza, chave trocada, `deviceId` novo, tabelas reabertas vazias, login novo sem visita pendente; no banco, tokens do id antigo revogados, linhas `wipe_ordered`/`wipe_delivered`/`wipe_confirmed` com o mesmo `orderId`, nenhuma visita do ACS no servidor, `audit_chain_check` verde (18 linhas). Log em `docs/superpowers/plans/2026-10-17-issue-81-e2e.log`. Testes: `device_wipe_service_test.dart`, `device_wipe_store_test.dart`, `device_wipe_endpoint_test.dart` (backend); `device_wipe_test.dart`, `remote_wipe_*`/painel aberto, `device_wiped` (ACS); `acs_devices_dialog_test.dart`, `touch_targets_test.dart` (admin). Screenshots das telas ainda não capturadas (`docs/telas-admin.md`, `docs/telas-acs.md`). +- Aberto: + - Aparelho offline para sempre não é alcançável (inerente ao modelo, D3). + - O JWT vivo vale até 15 min depois do pedido; a limpeza só chega na renovação seguinte ou numa consulta do app. + - A checagem da partida do ACS espera até 5 s pela consulta `pendingWipe` quando o servidor está mudo, antes de mostrar o login. + - Certificado de cliente MQTT por aparelho (D8, STRIDE-S): issue de continuação (a abrir); rascunho em `.superpowers/sdd/2026-10-17-issue-81-wipe-remoto-e-rotacao-de-chave/followup-issue.md`. + - Prova só na API 36 do emulador; API 24–29 e aparelho físico sem prova. + - `pendingWipe`/`confirmWipe` são públicos: quem tem o `deviceId` pode carimbar entregue/confirmado sem apagar nada (o acesso segue bloqueado, porque o id antigo fica marcado para sempre). "Aparelho limpo" no admin é a afirmação do aparelho, não prova. + - O diário é de uma vaga só: um pedido mais novo sobrescreve a confirmação pendente de um mais antigo (o `wipe_confirmed` do antigo se perde). Se a gravação de `erased` e a confirmação falharem juntas, a partida seguinte vê `erasing` e apaga de novo (raro; pode atingir visitas de um login novo). + - Escritas tardias do cache da microárea ou do cursor de `pull` depois da limpeza remota não estão provadas impossíveis: a desmontagem não congela a área. No e2e o arquivo não foi recriado, numa execução só. + - Teste de dispositivo que já falha em `develop`: "banco v6 ... migra para v7" em `apps/acs/integration_test/encrypted_storage_test.dart` espera `user_version` 7 e o schema é 8. + - O motivo escolhido não aparece no histórico do app admin (só no servidor); se a recarga da lista falha depois de um pedido bem-sucedido, a tela mostra a falha genérica. + - Conferidor `scripts/qa/cobertura_prd.py --final` falha em `develop` por uma linha do RNF04 sem negrito no status (anterior a esta issue). diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 0440cd7..039ef41 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -38,6 +38,8 @@ The ACS login is institutional now (RF07): `_enter` in `apps/acs/lib/app/app.dar **ACS: conflito de visita (#79, 2026-10-16).** O conflito sai da retentativa (D3): `VisitSyncResult.serverVisit` traz a cópia do servidor, `OfflineVisitQueue` guarda a visita em `_conflicts` com `conflictServerVersion`/`conflictServer` (schema v8, só `ADD COLUMN`), `resolveKeepLocal` (também usado por "Editar e enviar", com `outcome`/`notes`) chama `visits.resolveConflict` e `resolveKeepServer` só apaga a cópia local. Tela `resolve_conflicts` (chaves `conflict_*`). Detalhe e provas: `PROGRESS.md`, seção "Resolução de conflito de visita (#79)". +**ACS: limpeza remota (#81, 2026-10-17).** `RemoteWipeCoordinator` (`core/services/remote_wipe.dart`) aplica o pedido do backoffice com um só voo (`apply`, `resumePending`, `check`): grava o `WipeJournal` (`shared_preferences`, só `orderId` e `deviceId` antigo, fase `erasing`/`erased`) **antes** de apagar, apaga, grava `erased`, confirma por `devices.confirmWipe` e só então limpa o diário; `erasing` refaz a limpeza (idempotente) na partida, `erased` só reenvia a confirmação. Gatilhos: partida (a raiz espera `resumePending` e `check`, teto `startupWipeCheckTimeout` = 5 s com o servidor mudo), retomada do segundo plano, volta da rede e `DeviceWipeRequiredException` da renovação/login/envio diferido (`DeviceWipeRequiredFailure`, não irrecuperável como sessão expirada). O `DeviceEraser` de `app.dart` desmonta a interface (o painel fecha MQTT, reserva HTTP e timers), espera até 5 s um `sync` em voo, descarta as filas em RAM sem gravar, esquece a sessão local, chama `VisitDatabase.destroy()` (fecha, apaga arquivo, sidecars e `.recuperado`, apaga a chave do SQLCipher: é a rotação por evento, D6; repetível e exclusivo contra `open` concorrente), apaga os tokens de envio sem `revokeUploadToken` e o `deviceId` (`DeviceIdStore.delete`). Depois mostra `device_wiped` (sem `AppLockGate`) e "Entrar de novo" abre um login com `deviceId` novo. O wipe remoto ignora o bloqueio de visitas não enviadas (D2); "Limpar este aparelho" local continua recusando com `WipeBlocked` e agora também troca a chave. Nada chama `acknowledge`/`resolve` (INV-03). Prova: `./scripts/qa/acs_remote_wipe_e2e.sh` (emulador API 36, `integration_test/remote_wipe_e2e.dart`). + **ACS: reserva por HTTP dos alertas (#80, 2026-10-17).** `AlertHttpFallback` (`core/services/alert_http_fallback.dart`, sem `Timer`) chama `AcsBackend.openAlerts` (`alerts.openForMicroArea`) e funde em `AlertQueue.mergeFromServer`, que nunca remove cartão, ignora outra microárea, nunca desfaz ACK, conclusão ou reforço (só os ganha: um ACK feito em outro aparelho promove o cartão) e só notifica quando algo mudou. Queda do broker depois de conectar: com `autoReconnect` o `mqtt_client` 10.5.1 não chama `onDisconnected`, só `onAutoReconnect`, por isso `MqttSecureClient` trata os dois como queda (`test/mqtt_drop_after_connect_test.dart`) e usa `disconnectOnNoResponsePeriod = 10` contra a conexão meio aberta. O `AcsHomeShell` consulta sempre ao abrir o painel, ao voltar ao primeiro plano e no instante em que a reserva arma, e a cada `AcsHomeShell.defaultAlertFallbackInterval` (30 s; `alertFallbackInterval` nos testes) **só** enquanto o broker está desconectado e o app em primeiro plano; uma consulta por vez, e `close()` descarta a resposta que chega depois do `dispose`. Com a reserva ligada o painel mostra `Key('alert_fallback_status')` (`liveRegion`): "Recebendo alertas pelo servidor a cada 30 s. Última consulta às HH:MM." ou "…também falhou…"; o chip do cabeçalho continua "Sem central". Sessão vencida na consulta segue o `onSessionExpired` de sempre. Prova: `scripts/qa/acs_alert_fallback_e2e.sh` (broker fora do alcance do aparelho, alerta inserido por SQL sem `alert_outbox`, intervalo de produção; estrutura guardada por `acs_alert_fallback_e2e_test.sh`). Sem consulta em segundo plano e sem alertas gravados no aparelho. **ACS: fila de visitas por dono, envio diferido e "Limpar este aparelho" (2026-10-04).** Plano `docs/superpowers/plans/2026-10-03-fila-de-visitas-por-dono-e-corrida-do-login.md` (D1–D9). `offline_visits` tem a coluna `owner` (schema v7, migração aditiva): `VisitStorage.forOwner(userId)` só lê, conta, grava e apaga `WHERE owner = ?` (o `save()` de B nunca toca nas linhas de A; dono vazio é `ArgumentError`; `localId` já existente em outro dono é `VisitLocalIdConflict`, unicidade global). Linhas do schema v6 ficam em **quarentena** (`owner IS NULL`): nenhum dono as vê; só `VisitStorage.legacy` (`LegacyVisitStore`) as lê e remove por `localId`. O cursor do pull é por `userId|microAreaId`; fila e pull são resolvidos por sessão (`visit_queue_factory.dart`, `visit_pull_service_factory.dart`) e recriados por dono, e `BackendVisitSynchronizer` recusa sessão ou token de outro dono (checagem no push e `expectedUserId` conferido depois de resolvido o token). Os testes que injetam `visitQueue`/`visitPullService` seguem como *seam*. `BackendClient._exclusive` (seção crítica serial, **não reentrante**: dentro dela só E/S local do Keystore e atribuições, nunca rede, `renewSession()` ou outra `_exclusive`) cobre login, `developmentLogin`, a leitura do token e a confirmação da renovação e o logout, e fecha a corrida em que uma renovação do usuário anterior sobrescrevia o login novo. `DeferredFlushService` (`core/services/deferred_flush_service.dart`) sobe o que não é do dono da sessão atual: o legado pela sessão atual como transporte (`syncLegacy`, "autoria desconhecida"), cada dono com token por `syncDeferred`; lotes de 100 (`deferredBatchSize`); a visita só sai do aparelho em `synced`; o token de envio é revogado no servidor e apagado do Keystore só com a lista do dono vazia; `flushAll()` é single-flight e não lança; o dono da sessão atual é pulado (a fila é do painel, por isso o "Sair" sincroniza a fila do painel **antes** de `flushAll`). `UploadTokenStore` (`core/security/secure_upload_token_store.dart`) guarda um token por dono no Keystore (`acs_upload_token|`) 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`). @@ -70,6 +72,8 @@ Package `sinalacs_admin`, backoffice — read-only on the Indicadores, Alertas a Login is real: `LoginScreen` (`lib/app/login_screen.dart`) calls `AdminAuthBackend.login` (`BackendAdminAuth` over `auth.loginStaff`, `lib/core/auth/admin_auth_backend.dart`) with matrícula + senha + TOTP (`totp_field` appears on `AdminMfaCodeRequired`; changing matrícula/senha discards it; `AdminMfaEnrollmentRequired` opens `MfaEnrollmentScreen`, QR + `mfa_secret`, nothing stored on the device). `SinalAdminApp` requires `auth:`; there is no bypass and `devLoginEnabled` no longer exists — `AdminHomeShell` needs the `AdminSession` the backend returned, and an expired session (checked at tab change and by a one-shot timer at `expiresAt`) sends the user back to login with "Sessão encerrada. Entre novamente.". `main.dart` builds the auth with `buildAdminAuth` (`admin_auth_bootstrap.dart`): https-only host, and the dev RPC CA from `assets/certs/dev_rpc_ca.crt` (copied by `scripts/dev/sync_dev_ca.sh`, gitignored, CI creates the empty folder) is the only extra trust; without it the client stays fail-closed. Tests use `test/support/fake_admin_auth.dart` and `entrarComCredenciais` in the layout harness. Layout is desktop-first per `spec/PRD_system.md` §2.1: `NavigationRail` above `AdminBreakpoints.rail`, `NavigationBar` below, same `ThemeData` either way. The breakpoints live in `lib/app/admin_layout.dart` (`rail` 640, `stacked` 480, `counterCardMin` 160) plus `adminHeaderHeight()`, which exists because `PreferredSizeWidget.preferredSize` is a getter with no `BuildContext` — without it the two-line header clips instead of growing under a large system font. Android was added after the web-only phase, and mobile is a complement, never a replacement: below `stacked` the two alert filters stack instead of sharing a row, `_LinhaComSelo` moves a `ListTile`'s `trailing` badge down into the subtitle (Microáreas and Auditoria had the same squeeze), `_InfoRow` stacks label over value, the header's "Acesso auditado" chip becomes an icon that keeps its `Semantics` label, and the counter cards use a computed grid instead of a fixed `width: 160`. `NavigationRail` is `scrollable: true` because a phone in landscape clears the 640 threshold with only ~288dp of height left — it fits by a few pixels at default font size and overflows at 150%. The guard against regressing any of this is that the ten original widget tests still pass at 800×600 (unedited until #44, whose "N vermelhos vencidos" banner pushed `alert_alert-3` of `alerts_screen_test.dart` a few pixels below the fold, so both lookups of that card — the positive one and the `findsNothing` after the filter — now pass `skipOffstage: false`, the negative one so it cannot pass just because the card sits below the fold; nothing else in them changed); the new `test/responsive_layout_test.dart`, `text_scale_test.dart`, `alerts_filters_layout_test.dart`, `admin_header_test.dart` and `touch_targets_test.dart` drive `test/support/layout_harness.dart`, whose header documents the three ways an overflow test silently goes blind (checking before paint, never scrolling a `ListView` past the first fold, and `takeException()` consuming one exception per call) — `test/layout_harness_sanity_test.dart` proves the detector still detects. **`apps/admin/integration_test/*_test.dart` is hermetic**, unlike the ACS and patient ones described above: it runs on `MockAdminDataSource` and a fake auth, so `flutter test integration_test/admin_mobile_smoke_test.dart -d emulator-5554` (which is also what `scripts/qa/e2e.sh --emulator --full` runs for the admin) needs no `docker compose`, no seed and no `--dart-define`. The one exception is `integration_test/admin_login_e2e.dart` (suffix `_e2e` on purpose, so discovery skips it): the real login and, since #40, the real panel (three seeded alerts → Vermelho 2, Verde 1, TMRAV 60s, labels only, the admin's own read in the audit list) against the e2e database, run by `scripts/qa/admin_login_e2e.sh` (same pattern as `acs_full_e2e.sh`: `e2e_stack.sh`, a synthetic staff admin without MFA from `seed_e2e_fixtures.dart`, credential delivered once by the relay at `/admin`, `support/totp.dart` an identical copy of the ACS one, host `https://localhost:8443/` over `adb reverse`, dev CA from the `assets/certs/dev_rpc_ca.crt` asset). The panel data is real since issue #40. Since #43 there is a second real-backend journey, `integration_test/admin_acs_gestao_e2e.dart` (also suffixed `_e2e`; `@Timeout(minutes: 6)` because of the real 30 s TOTP windows), run by `scripts/qa/admin_acs_gestao_e2e.sh` (same anatomy, plus the relay's `/coordenador` route for the seeded coordinator): the admin reads the initial password off the screen and logs in with it over RPC, links the ACS to another micro-area, resets the ACS MFA and the coordinator's MFA (the code emitted by the screen activates the new MFA), deactivates the account (login and refresh refused) and, in the coordinator's journey, proves he does not see "Equipe do backoffice" after scrolling the whole screen but does see and manage the ACS of his own UBS; the script then asserts the database state over `psql` (deactivated ACS, no live refresh token, the four `totp*` columns null, the activation code issued, the write rows in `audit_logs`). `apps/admin` is the only one of the three apps with Flutter Web enabled and the only one versioning `pubspec.lock` from the start (the other two adopted that convention later). `docs/telas-admin.md` documents its screens with real screenshots (the #43 management flows are described in text, with the missing screenshots named as pending — never invented), mirroring `docs/telas-acs.md`. CI covers it in the `admin-app` job, identical in shape to `acs-app`/`patient-app`. +**Aparelhos do ACS e limpeza remota (#81).** O cartão do ACS em `admin_accounts.dart` ganhou o botão `acs_devices_` ("Aparelhos"), que abre `acs_devices_dialog.dart`: lista de `AcsDevice` (rótulo, último acesso, estado Pedido/Entregue/Limpo), botão `wipe_device_` escondido quando já há pedido, etapa do motivo (`DeviceWipeReason`: Perdido, Roubado, Substituído) e confirmação destrutiva (`redOnSurface`) que avisa que visitas não enviadas serão perdidas. Pedido repetido devolve `alreadyOrdered`; recusa (fora da UBS ou aparelho alheio) mostra texto fixo. "Limpo" é a afirmação do aparelho, não prova (ver `PROGRESS.md`, #81). Fontes: `fetchAcsDevices`/`orderDeviceWipe` em `admin_data_source.dart` e `backend_admin_data_source.dart`. + **Escalonar e tempo quase real (#44).** `MonitoringController` (`lib/app/monitoring_controller.dart`), dono do `AdminHomeShell`, chama **só** `admin.alertsPulse` a cada 30 s (timeout do pulso 20 s) enquanto Indicadores ou Alertas estão montadas (`MonitoredScreenState`); pausa em `paused`/`hidden` (não em `inactive`, para o painel numa segunda tela seguir atualizando) e manda `endMonitoringSession` sem `await`. O painel se atualiza pelo pulso (`admin.indicators` não é consultado); a lista de alertas é relida (só alertas, não microáreas) quando `maxChangedAt` ou as contagens mudam. Sem pulso bem-sucedido há mais de 60 s (`staleAfter`) aparece o `StaleBadge` (ícone + texto "Dados de HH:MM:SS — sem atualização há N min", `liveRegion`, cor `overdueOnSurface`). `EscalateAlertButton` (`escalate_alert_dialog.dart`) só aparece quando o servidor diz `overdue`, pede o motivo (conjunto fechado) e, depois do resultado, chama `monitoring.refreshNow()` (pulso imediato fora do ciclo). Painel e Alertas mostram, ao lado do título (`TitleWithOverdueRed`, `lib/app/overdue_red_banner.dart`, num `Wrap` para não empurrar a lista no desktop), a faixa "N vermelhos vencidos" (`Key('overdue_red_banner')`; singular "1 vermelho vencido"; some com 0 e antes do 1º pulso) a partir de `lastPulse.overdueRed`: ícone + texto em `redOnSurface` (par em `contrast_tokens_test.dart`), `Semantics(liveRegion: true)`, não é botão; o servidor lista os vencidos primeiro. O filtro de status da tela Alertas não oferece `AlertStatus.escalated` (sem escritor; escalonar mantém `pending`), mas `statusLabel` segue rotulando "Escalonado" nas linhas legadas (`test/overdue_red_banner_test.dart`). ACS: o backend republica o alerta com `escalated: true`; `PrioritizedAlert.escalated` sobe o cartão para o topo com o rótulo "Reforço da coordenação", sem nunca desfazer o ACK. **Limite conhecido:** o app do ACS não tem som, vibração nem notificação do SO; o reforço de um alerta já listado só é percebido com a lista aberta (ordem e rótulo; banner e contador não mudam). **E2E do escalonamento e do tempo real (#44).** `integration_test/admin_alertas_escalonar_e2e.dart` (sufixo `_e2e`, `@Timeout(minutes: 12)`), rodado só por `scripts/qa/admin_alertas_escalonar_e2e.sh [--com-regressao-offline]` (mesma anatomia dos outros: `e2e_stack.sh`, relé com `/coordenador`, `adb reverse`; estrutura guardada por `scripts/qa/admin_alertas_escalonar_e2e_test.sh`, sem Docker nem aparelho). A stack de e2e fixa `RED_ALERT_ESCALATION_SECONDS=30` (`docker-compose.e2e.yml`; a de desenvolvimento segue com 300) e o seed, com `E2E_SEED_ALERT_ESCALATION=1`, cria um vermelho pendente de 10 min na UBS do coordenador (bloco `escalationAlerts` do manifesto; ids passados ao teste por `--dart-define=E2E_ALERTA_VENCIDO/E2E_ALERTA_NOVO`). O teste fala com o script por **linhas marcadoras** lidas em tempo real da saída do `flutter test`: `E2E_DISPARAR_ALERTA`, impresso com o Painel aberto e parado, faz o script inserir por SQL o segundo alerta com `triggeredAt = NOW() + 20 s` (um alerta semeado ou criado "agora" já estaria vencido quando a tela abrisse; os +20 s deixam a janela "no prazo" maior que um pulso de 30 s), e `E2E_CORTAR_BACKEND` faz `docker pause sinalacs-traefik` por 120 s — a única rota do aparelho até o backend (`adb reverse tcp:8443` → :443 do host → Traefik → `https://serverpod:8080`, TLS 1.3 desde a #78; o 8080 não é publicado), enquanto o backend e a conexão MQTT dele (direto a `mosquitto:8883`) ficam no ar. Cortar o Wi-Fi do emulador não derruba o `adb reverse`, e pausar o próprio backend o derrubou na 1ª execução real (sem keepalive MQTT o broker fechou a conexão; no `unpause`, `SocketException: Broken pipe` e saída 255 — defeito latente do despachante, a acompanhar em issue própria). Depois do `unpause` o script confere que o backend segue no ar com o mesmo `StartedAt` e, se não, mostra o fim do log; o `cleanup` sempre despausa o Traefik (e o backend, por defesa). O pulso devolve `overdueRed` e `maxChangedAt` mais os indicadores embutidos, e o app compara os três para decidir recarregar a lista; o Painel desenha `lastPulse.indicators`. Sem tocar em filtro, puxar a lista nem trocar de aba nos trechos de prova, o teste mostra: o Painel passando de 1 a 2 abertos sozinho (`E2E_PAINEL_ATUALIZOU`, ≤ 40 s), botão ausente no prazo e presente ≤ 40 s depois dele (35 nominais + folga de relógio), escalonamento pela tela com motivo ("Pendente" + "Escalonado às"), painel ainda com 2 vermelhos abertos, reescalonamento por RPC com `alreadyEscalated` e o mesmo `escalatedAt` (o token vem da `AdminSession` capturada no `dataSourceFor`, sem segundo login) e o selo `stale_badge` que aparece na queda com os dados mantidos e some sozinho na volta. O script confere no banco uma linha `escalated:no_ack_timeout` e uma `already_escalated:no_ack_timeout`, o alerta `pending` com `escalatedBy` do coordenador, um único reforço no `alert_outbox`, `monitoring_session_start`, zero `read` do `admin_pulse`, os tetos de leitura do coordenador por recurso (indicadores 4, alertas 9, microáreas 3, auditoria 0; total 16) e a cadeia de auditoria. `--com-regressao-offline` roda `acs_full_e2e.sh` (a jornada offline do ACS) ao fim. Passo a passo manual no cabeçalho do script. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 6ff57c2..a10d19c 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -110,6 +110,8 @@ microárea **do paciente** e a do ACS **não** passa por ela e continua inline e cada caso de uso (`onboarding_service.dart:112-113`, `visit_sync_service.dart:252-262`), porque a guarda nunca vê o paciente. Os endpoints com postura integralmente autenticada estendem `AuthenticatedEndpoint` (`lib/src/endpoints/authenticated_endpoint.dart`), que centraliza a verificação do token; `test/unit/endpoint_auth_posture_test.dart` lê o texto-fonte de `lib/src/endpoints/` e falha se um endpoint novo não estender a base nem constar da allowlist justificada. `requireLogin` continua `false` em todos: o `AuthenticationHandler` do Serverpod não está conectado. +**Limpeza remota do aparelho (#81).** Tabela `device_wipe_orders` (migração `20261017171640323`): `deviceId` com índice único (o wipe troca o id, então o antigo nunca volta), `targetUserId`, `requestedBy`, `reason` (`DeviceWipeReason`: `lost`/`stolen`/`replaced`), `requestedAt`, `deliveredAt`, `confirmedAt`. `DeviceWipeService` (`application/devices/device_wipe_service.dart`, store `OrmDeviceWipeStore`, ligada em `AlertRuntime.deviceWipeServiceFor`). Backoffice: `admin.acsDevices` lista os aparelhos do ACS (união de `acs_refresh_tokens` e `acs_upload_tokens` por `deviceId`; o sentinela `InstitutionalAuthService.deviceIdAbsent` nunca entra) e `admin.orderDeviceWipe` grava o pedido e revoga, na mesma transação, os tokens de **todos** os usuários daquele `deviceId`; escopo igual ao da #43 (coordenador só da UBS, admin todos) e fora do escopo ou inexistente dão a **mesma** recusa `Aparelho não encontrado.` com linha `denied`; sem cancelamento; repetir devolve `alreadyOrdered`. Aparelho: `devices.pendingWipe(deviceId)` devolve só o `orderId` (`String?`) e `devices.confirmWipe(deviceId, orderId)` não devolve nada e não distingue par errado; os dois são públicos por desenho e estão na allowlist de `endpoint_auth_posture_test.dart`. **Checagem de autenticação:** `ensureDeviceNotMarked(deviceId)` roda primeiro em `auth.loginInstitutional`, `auth.refreshSession` e `visits.syncDeferred` e lança `DeviceWipeRequiredException(orderId)` para qualquer aparelho com pedido (também depois de confirmado: o id antigo fica marcado para sempre). Auditoria: pedido `write`/`admin_device` (`wipe_ordered`/`already_ordered`), leitura `read`/`admin_acs_devices`, aparelho `write`/`acs_device` (`wipe_delivered` só na 1ª entrega, `wipe_confirmed`), carimbo e linha na mesma transação. Alertas no servidor não são tocados (INV-03). Limite: quem sabe o `deviceId` pode carimbar entregue/confirmado sem apagar nada (o acesso continua bloqueado). + **Avisos agendados e versões dos termos (`AdminEndpoint`, issue #46).** Sete métodos novos, todos `(Session, {required String accessToken, …})`: `admin.scheduleNotice`, `admin.cancelScheduledNotice`, `admin.noticeHistory` (avisos; coordenador e admin, o coordenador só na microárea da própria UBS) e `admin.termsVersions`, `admin.draftTermsVersion`, `admin.markTermsReviewed`, `admin.publishTermsVersion` (termos; só admin). Duas tabelas novas: `scheduled_notices` (ciclo `pending → sending → sent | failed | unknown | cancelled`, `ScheduledNoticeStatus`) e `terms_versions` (rascunho → revisado juridicamente → publicado). - **Avisos:** `NoticeSchedulingService` (`application/notices/`) sobre `ScheduledNoticeStore` (`OrmScheduledNoticeStore`). A reserva `pending → sending` é um `UPDATE` atômico, então um aviso nunca sai duas vezes. O disparo reaproveita `NoticeService.sendToMicroArea`, a mesma auditoria do envio imediato. O histórico não devolve o texto do aviso e a trilha não o grava. Negações gravam `denied_role`/`denied_territory` antes de lançar. diff --git a/docs/auditoria-prd/2026-10-15-lacunas-prd.md b/docs/auditoria-prd/2026-10-15-lacunas-prd.md index 3d456bd..ac52846 100644 --- a/docs/auditoria-prd/2026-10-15-lacunas-prd.md +++ b/docs/auditoria-prd/2026-10-15-lacunas-prd.md @@ -79,7 +79,7 @@ próprio; o conferidor exige apenas os IDs do PRD. | ID | Requisito | Status | Evidência | O que falta | |----|-----------|--------|-----------|-------------| -| STRIDE-S | Spoofing: mensagens falsas no MQTT | **parcial** | Broker com senha + ACL por tópico + certificado de cliente obrigatório (`infra/docker/mosquitto/mosquitto.conf:10`); `scripts/qa/mtls_invariants.sh` OK (2026-10-15). | Certificado de cliente por aparelho em produção (hoje um certificado de dev compartilhado, `acs-area-12.crt`). | +| STRIDE-S | Spoofing: mensagens falsas no MQTT | **parcial** | Broker com senha + ACL por tópico + certificado de cliente obrigatório (`infra/docker/mosquitto/mosquitto.conf:10`); `scripts/qa/mtls_invariants.sh` OK (2026-10-15). | Certificado de cliente por aparelho em produção (hoje um certificado de dev compartilhado, `acs-area-12.crt`); adiado na #81 (D8) para a issue de continuação (a abrir). | | STRIDE-T | Tampering: triagem alterada no dispositivo | **parcial** | O risco é recalculado no servidor (INV-02), então adulterar a triagem no aparelho não muda a classificação. | Não há a assinatura digital dos dados de triagem no dispositivo que o PRD descreve; decidir se a reclassificação no servidor substitui a assinatura e registrar. | | STRIDE-R | Repudiation: logs append-only assinados | **parcial** | Cadeia HMAC em `audit_logs` detecta remoção e alteração (`audit_chain_check`). | Append-only no banco (trigger/`REVOKE`) não existe: ver RF17. | | STRIDE-I | Information disclosure: PII no dispositivo | **entregue** | SQLCipher no ACS com chave no Keystore (`apps/acs/lib/core/security/database_key_store.dart`), `FLAG_SECURE` na janela do ACS (`secure_window_test`). | O app do paciente guarda lembretes de saúde em texto livre (medicamento, pesagem: `apps/patient/lib/core/reminders/reminder.dart:1`) em `sqflite` sem cifra, por decisão; reavaliar se o campo passar a aceitar diagnóstico. | @@ -91,7 +91,7 @@ próprio; o conferidor exige apenas os IDs do PRD. | ID | Requisito | Status | Evidência | O que falta | |----|-----------|--------|-----------|-------------| | CRIPTO-APP-LOCAL | SQLCipher com chave derivada de PIN/biometria | **parcial** | SQLCipher com chave aleatória de 32 bytes guardada no Keystore (`apps/acs/lib/core/security/database_key_store.dart:31`); o arquivo documenta por que não usa PBKDF2 de PIN (`:8`). | Divergência do PRD (§4.2.3 pede chave derivada de PIN/biometria por PBKDF2): atualizar o PRD com a decisão ou amarrar a chave à biometria. | -| CRIPTO-APP-CACHE | SQLCipher com chave rotativa | **ausente** | A mesma chave do SQLCipher é usada por toda a vida da instalação; nenhuma rotação. | Rotação da chave do cache (mudança de dispositivo) ou decisão registrada de não rotacionar. | +| CRIPTO-APP-CACHE | SQLCipher com chave rotativa | **entregue** | Rotação por evento (#81, D6): `VisitDatabase.destroy` troca a chave no "Limpar este aparelho" e na limpeza remota; decisão de não rotacionar por calendário registrada em `spec/lgpd_design.md` §5.1; `device_wipe_test.dart`, e2e `acs_remote_wipe_e2e.sh` (chave trocada, banco reaberto vazio). | | | CRIPTO-APP-TRAEFIK | TLS 1.3 app ↔ Traefik | **entregue** | Traefik com `minVersion: VersionTLS13` (`infra/docker/traefik/dynamic/tls.yaml:22`); `scripts/qa/tls_invariants.sh` OK e `openssl s_client -tls1_2` na 443 recusado (2026-10-15). | Certificado Let's Encrypt em produção (hoje CA de dev). | | CRIPTO-TRAEFIK-BACKEND | mTLS Traefik ↔ backend | **parcial** (2026-10-16, #78) | Traefik → backend em TLS 1.3 de mão única com a cadeia verificada: o backend escuta TLS na 8080 interna (`API_TLS_*`, `ApiTlsConfig`), o Traefik disca por `https` com o `serversTransport` `backend-tls` (`rootCAs` + `serverName`, sem `insecureSkipVerify`). Provado pelo Grupo E de `tls_invariants.sh`. | mTLS: bloqueado pelo Serverpod 3.4.13, que sobe o socket pelo `relic_io` (`HttpServer.bindSecure` sem `requestClientCertificate`); decisão registrada em `PROGRESS.md`. | | CRIPTO-POSTGRES | Criptografia no PostgreSQL | **entregue** | AES-256-GCM na aplicação com `HEALTH_DATA_ENCRYPTION_KEY` (decisão §2.2.1 §6, que substituiu pgcrypto); ver INV-04. | Chave em gerenciador de segredos (Vault) em produção; hoje variável de ambiente. | @@ -212,7 +212,7 @@ próprio; o conferidor exige apenas os IDs do PRD. |----|-----------|--------|-----------|-------------| | 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 | **entregue** (2026-10-17, #80) | Reconexão com backoff e sessão persistente no MQTT; outbox no servidor reentrega (`mqtt_resiliencia_e2e.sh`); visitas já vão por HTTP; reserva por HTTP dos alertas: `alerts.openForMicroArea` consultado pelo ACS a cada 30 s enquanto o broker está fora (`AlertHttpFallback`, `AlertQueue.mergeFromServer`); no `emulator-5554`, alerta sem outbox chegou à tela em 29,6 s e foi confirmado (`scripts/qa/acs_alert_fallback_e2e.sh`). | Consulta só em primeiro plano (sem WorkManager, fora de `spec/stack.md`); nenhum alerta gravado no aparelho (decisão D5 da #80). | -| 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. | +| R3 | Vazamento de PII no dispositivo | **entregue** | Limpeza do aparelho pelo próprio ACS (`device_wipe_test.dart`); chave do SQLCipher no Keystore; limpeza remota pelo backoffice (#81, 2026-10-17): `admin.orderDeviceWipe`, `devices.pendingWipe`/`confirmWipe`, `RemoteWipeCoordinator`; prova no emulador API 36 em `./scripts/qa/acs_remote_wipe_e2e.sh` (log `docs/superpowers/plans/2026-10-17-issue-81-e2e.log`). | Entregue, exceto o certificado de cliente por aparelho (ver STRIDE-S). Abertos no `PROGRESS.md` (#81): aparelho offline para sempre, JWT vivo até 15 min, API 24–29 e aparelho físico sem prova. | | R4 | Rejeição do ACS | **externo** | Nada no código. | NPS semanal e treinamento (produto + UBS). | | R5 | Performance do PostgreSQL | **parcial** | 46 índices na migração `20261014155304871`, incluindo `alerts_micro_area_status_idx` e `users_micro_area_idx`. | Particionamento por microárea não existe; sem teste de carga que mostre necessidade (M3.3). | | R6 | Dependência de SMS gateway | **parcial** | O ACS pode gerar quantas vezes quiser um convite QR de uso único para paciente da microárea dele (`backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart:21`), e `completeEnrollment` emite uma sessão do paciente (`onboarding_endpoint.dart:37,70`): é metade do fallback por QR. Nenhum provedor de SMS (RF01). | Entrada sem o ACS presente quando o SMS falha; biometria; múltiplos provedores de SMS. | @@ -231,7 +231,7 @@ Critério: **P0** toca invariante ou o núcleo do MVP (§6.1) sem critério atin | 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; 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~~ — resolvido na #80 (2026-10-17): `alerts.openForMicroArea` consultado a cada 30 s com o broker fora | R2, INV-03 | código | Risco de impacto 5 em rede 3G; antes, o ACS não tinha 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. | +| 7 | P1 | ~~Remote wipe~~ e certificado de cliente por aparelho — o remote wipe e a rotação da chave foram resolvidos na #81 (2026-10-17); o certificado por aparelho segue na issue de continuação (a abrir) | R3, STRIDE-S, CRIPTO-APP-CACHE | código + infra | Risco de impacto 5; o certificado depende de uma lib X.509/CSR fora de `spec/stack.md`, de uma CA de aparelhos 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. | | 9 | P1 | Telemetria estruturada dos eventos do §1.3 | EVT-*, RNF02, H1, H2, H4, MOD-RELATORIOS | código | Sem os eventos, a North Star e três hipóteses não são mensuráveis no piloto. | | 10 | P2 | Rate limiting no Mosquitto e no Traefik | STRIDE-D | infra | DoS listado no STRIDE sem mitigação. | diff --git a/docs/telas-acs.md b/docs/telas-acs.md index df8fe11..b60882f 100644 --- a/docs/telas-acs.md +++ b/docs/telas-acs.md @@ -54,6 +54,12 @@ Exibe uma proximidade sintética e atalho para o formulário de visita. Rastream Permite preparar público-alvo e mensagem comunitária. O envio depende da futura integração FCM/APNs e das regras de segmentação por microárea. +## Aparelho limpo pela coordenação (issue #81) + +Quando a coordenação ordena a limpeza do aparelho, o app a aplica sozinho na partida, ao voltar do segundo plano, quando a rede volta ou na renovação da sessão. Se o ACS estava com o painel ou um formulário aberto, tudo some sem aviso prévio: a fila em memória é descartada sem gravar, o banco cifrado, a chave e os tokens de envio são apagados e aparece a tela **Aparelho limpo pela coordenação** (`device_wiped`), que explica que os dados foram apagados a pedido da coordenação da UBS, inclusive as visitas não enviadas, e que os alertas continuam com a equipe da UBS. Se a confirmação ao servidor ainda não saiu (sem rede), a tela mostra uma linha avisando que a confirmação está pendente. O botão **Entrar de novo** leva ao login, que funciona normalmente com uma identidade de aparelho nova. Na partida, o login espera até 5 s pela consulta quando o servidor não responde. + +**Capturas pendentes:** a tela `device_wiped` não foi capturada (a janela do ACS é `FLAG_SECURE`; ver "Captura de tela" abaixo). + ## Referências - [Implementação Flutter](../apps/acs/lib/app/app.dart) diff --git a/docs/telas-admin.md b/docs/telas-admin.md index 830d5d0..7a4053e 100644 --- a/docs/telas-admin.md +++ b/docs/telas-admin.md @@ -54,6 +54,12 @@ A segunda seção lista as contas de coordenação e administração (matrícula **Capturas pendentes:** as imagens da gestão (seção de ACS com os cartões e as quatro ações, formulário de Novo ACS, diálogo de vínculo, confirmação destrutiva, credencial mostrada uma única vez e a seção Equipe) **não foram capturadas** — as capturas 03 a 08 acima são anteriores à #43. Elas exigem uma sessão real no emulador contra o banco de teste; até lá, o texto descreve o que a tela faz e nenhuma imagem foi inventada. +### Aparelhos do ACS e limpeza remota (issue #81) + +Cada cartão de ACS tem o botão **Aparelhos**, que abre um diálogo com os aparelhos em que aquela conta entrou (rótulo curto, "Último acesso em DD/MM às HH:MM" e o estado: sem pedido, "Limpeza pedida", "Pedido entregue ao aparelho" ou "Aparelho limpo", com a hora quando existe). Sem aparelhos, o diálogo diz isso em texto. Cada aparelho sem pedido tem o botão **Limpar remotamente**: primeiro o motivo (**Perdido**, **Roubado** ou **Substituído**), depois uma confirmação destrutiva "Limpar aparelho" (vermelho `redOnSurface`) que avisa que o aparelho perde o acesso na hora e apaga os dados, inclusive **visitas ainda não enviadas**. Pedir de novo devolve o pedido original; fora da UBS (coordenador) ou aparelho que o ACS nunca usou dão a mesma recusa em texto fixo. Depois do pedido a lista é relida do servidor. "Aparelho limpo" é a confirmação enviada pelo próprio aparelho, não uma prova de que ele apagou (ver `PROGRESS.md`, seção #81). + +**Capturas pendentes:** a lista de aparelhos, a etapa do motivo e a confirmação destrutiva **não foram capturadas**; nenhuma imagem foi inventada. + ## Alertas da UBS ![Consulta de alertas filtrável](screenshots/admin/04-alertas.png) diff --git a/spec/lgpd_data_audit.md b/spec/lgpd_data_audit.md index 4446911..3fc714d 100644 --- a/spec/lgpd_data_audit.md +++ b/spec/lgpd_data_audit.md @@ -196,6 +196,11 @@ A tabela abaixo consolida o mapeamento exaustivo de dados persistidos pelo backe | | `deviceId` | `text` | Identificador de aparelho | Texto informado pelo app | Mesma classificação do §2.4; token apresentado por outro aparelho é recusado e revogado. Um vigente por (`userId`, `deviceId`): novo login no mesmo aparelho revoga o anterior. | | | `issuedAt` / `expiresAt` | `timestamp without time zone` | Metadado Técnico | Emissão e teto fixo de 7 dias | Sem dado de saúde. O login poda os vencidos do próprio usuário; não há limpeza periódica da tabela. | | | `revokedAt` | `timestamp without time zone` | Metadado Técnico | `null` até a revogação | Preenchido pelo app (`visits.revokeUploadToken`, quando a fila do dono zera), por novo login no mesmo aparelho, por aparelho divergente ou por conta inativa/sem microárea. Sem dado de saúde. | +| **device_wipe_orders** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador do pedido de limpeza remota do aparelho (#81). | +| | `deviceId` | `text` | Identificador de aparelho | Id da instalação do app (UUID aleatório), **pseudonimizado**, não atestação de hardware | Mesma classificação do §2.4. Índice único: um pedido por id, e o app troca o id ao limpar, então o antigo nunca volta. Retido como evidência de auditoria do pedido; não há expurgo. | +| | `targetUserId` / `requestedBy` | `uuid` | Pseudonimizado | UUID do ACS alvo e do operador do backoffice que pediu | Sem dado de saúde; liga o pedido às contas. | +| | `reason` | `text` | Metadado Técnico | Enum `DeviceWipeReason`: `lost`, `stolen`, `replaced` | Conjunto fechado; sem texto livre. | +| | `requestedAt` / `deliveredAt` / `confirmedAt` | `timestamp without time zone` | Metadado Técnico | Pedido, 1ª entrega ao aparelho e confirmação do aparelho | `deliveredAt`/`confirmedAt` são afirmações do aparelho (`devices.pendingWipe`/`confirmWipe` são públicos), não prova de que apagou. | | **micro_area_cache** (local, aparelho do ACS, sob SQLCipher) | `patient_id` | `text` | Pseudonimizado | UUID do paciente | Chave da linha. Cache RF08 (`spec/lgpd_design.md` §5.11); só no aparelho, nunca enviado. | | | `name` | `text` | Identificável Pessoal | Nome do paciente em texto claro **dentro** da base SQLCipher | Mesma classificação de `patients.name`; no aparelho, sob SQLCipher (INV-04). Validade de 72 h, por dono. | | | `is_chronic` | `integer` | Dado Sensível de Saúde (derivado) | 0/1 | Mesma classificação de `patients.isChronic`. | diff --git a/spec/lgpd_design.md b/spec/lgpd_design.md index 1ddaa7b..1767a7f 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -586,8 +586,9 @@ CPFs de documentação (como faz `seed_cpf_hashes.dart` para a stack de desenvol | **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. | +| **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 **se a ação for recusada**; quando a limpeza local conclui, o arquivo é apagado e a chave do SQLCipher é trocada (#81). | +| **Limpeza remota e rotação da chave (#81)** | O coordenador da UBS ou o administrador ordena a limpeza de um aparelho do ACS (motivo: perdido, roubado ou substituído). O pedido revoga, na mesma transação, os tokens de todos os usuários daquele `deviceId`; o app, ao receber, apaga **tudo**: banco SQLCipher (visitas pendentes, em conflito, recusadas, legadas e de outros donos, cursor e cache da microárea), sidecars, a cópia `.recuperado`, a chave do Keystore, os tokens de envio e o `deviceId`, e mostra a tela `device_wiped`. O pedido é irreversível (sem cancelamento). **Rotação da chave por evento, não por calendário (D6):** a chave do SQLCipher é trocada na limpeza remota e em "Limpar este aparelho" (`VisitDatabase.destroy`); não há `PRAGMA rekey` periódico, porque ele reescreve o arquivo inteiro com visitas pendentes dentro e uma falha no meio arrisca justamente o que a fila protege. Um diário local (`WipeJournal`, só `orderId` e `deviceId` antigo, nada sensível) é gravado antes de apagar, para a limpeza terminar mesmo se o processo morrer. O servidor guarda em `device_wipe_orders` o `deviceId` (id pseudonimizado de instalação, §2.4), quem pediu, o motivo e as datas de pedido, entrega e confirmação, como evidência de auditoria. Alertas no servidor não são tocados (INV-03). **Limites:** aparelho offline para sempre não é alcançável; o JWT vivo (até 15 min) segue válido até a renovação; `confirmWipe` é público, então "Limpo" no backoffice é a afirmação do aparelho, não prova; escritas tardias do cache da microárea após a limpeza não estão provadas impossíveis. | +| **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; o wipe forçado pela coordenação existe desde a #81 (linha "Limpeza remota e rotação da chave" acima) e não alcança aparelho que nunca mais se conecta. **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. | | **O que é gravado** | `patient_id` (UUID), risco, status, desfecho, data e versão. **Não** é gravado nome, rótulo legível nem endereço. As observações de campo digitadas na tela ainda não são persistidas nem enviadas. | | **Por que o identificador, e não o rótulo** | A tela antes gravava `'Paciente ' + 8 dos 32 dígitos hex` e descartava o UUID. Era pior nos dois sentidos: texto legível sobre a pessoa no disco, e um identificador irrecuperável — `visits.sync` só aceita UUID, então a visita nunca poderia sair do aparelho e a retenção nunca a alcançaria. Guardar o identificador e montar o rótulo na tela é o desenho de minimização correto (LGPD-RF01). | From eb8e22ca2732dec0a8ae74a88248386b09655a00 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 16:41:43 -0400 Subject: [PATCH 13/17] =?UTF-8?q?docs:=20R3=20segue=20parcial=20na=20audit?= =?UTF-8?q?oria=20do=20PRD=20e=20ajusta=20afirma=C3=A7=C3=B5es=20sobre=20a?= =?UTF-8?q?=20base=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 5 +++-- docs/auditoria-prd/2026-10-15-lacunas-prd.md | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 4baa92a..0ff4996 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1706,10 +1706,11 @@ Backlog priorizado na seção final do relatório; conferência por `python3 scr - O JWT vivo vale até 15 min depois do pedido; a limpeza só chega na renovação seguinte ou numa consulta do app. - A checagem da partida do ACS espera até 5 s pela consulta `pendingWipe` quando o servidor está mudo, antes de mostrar o login. - Certificado de cliente MQTT por aparelho (D8, STRIDE-S): issue de continuação (a abrir); rascunho em `.superpowers/sdd/2026-10-17-issue-81-wipe-remoto-e-rotacao-de-chave/followup-issue.md`. + - R3 do PRD segue **parcial** (`docs/auditoria-prd`): faltam também a chave derivada de biometria que o PRD pede (a do SQLCipher fica no Keystore sem amarração, §5.1). - Prova só na API 36 do emulador; API 24–29 e aparelho físico sem prova. - `pendingWipe`/`confirmWipe` são públicos: quem tem o `deviceId` pode carimbar entregue/confirmado sem apagar nada (o acesso segue bloqueado, porque o id antigo fica marcado para sempre). "Aparelho limpo" no admin é a afirmação do aparelho, não prova. - O diário é de uma vaga só: um pedido mais novo sobrescreve a confirmação pendente de um mais antigo (o `wipe_confirmed` do antigo se perde). Se a gravação de `erased` e a confirmação falharem juntas, a partida seguinte vê `erasing` e apaga de novo (raro; pode atingir visitas de um login novo). - Escritas tardias do cache da microárea ou do cursor de `pull` depois da limpeza remota não estão provadas impossíveis: a desmontagem não congela a área. No e2e o arquivo não foi recriado, numa execução só. - - Teste de dispositivo que já falha em `develop`: "banco v6 ... migra para v7" em `apps/acs/integration_test/encrypted_storage_test.dart` espera `user_version` 7 e o schema é 8. + - Teste de dispositivo que já falhava antes desta branch (medido na base dela): "banco v6 ... migra para v7" em `apps/acs/integration_test/encrypted_storage_test.dart` espera `user_version` 7 e o schema é 8. - O motivo escolhido não aparece no histórico do app admin (só no servidor); se a recarga da lista falha depois de um pedido bem-sucedido, a tela mostra a falha genérica. - - Conferidor `scripts/qa/cobertura_prd.py --final` falha em `develop` por uma linha do RNF04 sem negrito no status (anterior a esta issue). + - Conferidor `scripts/qa/cobertura_prd.py --final` falha por uma linha do RNF04 sem negrito no status; falhava igual com as mudanças desta issue guardadas (medido na base da branch). diff --git a/docs/auditoria-prd/2026-10-15-lacunas-prd.md b/docs/auditoria-prd/2026-10-15-lacunas-prd.md index ac52846..20fcc63 100644 --- a/docs/auditoria-prd/2026-10-15-lacunas-prd.md +++ b/docs/auditoria-prd/2026-10-15-lacunas-prd.md @@ -212,7 +212,7 @@ próprio; o conferidor exige apenas os IDs do PRD. |----|-----------|--------|-----------|-------------| | 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 | **entregue** (2026-10-17, #80) | Reconexão com backoff e sessão persistente no MQTT; outbox no servidor reentrega (`mqtt_resiliencia_e2e.sh`); visitas já vão por HTTP; reserva por HTTP dos alertas: `alerts.openForMicroArea` consultado pelo ACS a cada 30 s enquanto o broker está fora (`AlertHttpFallback`, `AlertQueue.mergeFromServer`); no `emulator-5554`, alerta sem outbox chegou à tela em 29,6 s e foi confirmado (`scripts/qa/acs_alert_fallback_e2e.sh`). | Consulta só em primeiro plano (sem WorkManager, fora de `spec/stack.md`); nenhum alerta gravado no aparelho (decisão D5 da #80). | -| R3 | Vazamento de PII no dispositivo | **entregue** | Limpeza do aparelho pelo próprio ACS (`device_wipe_test.dart`); chave do SQLCipher no Keystore; limpeza remota pelo backoffice (#81, 2026-10-17): `admin.orderDeviceWipe`, `devices.pendingWipe`/`confirmWipe`, `RemoteWipeCoordinator`; prova no emulador API 36 em `./scripts/qa/acs_remote_wipe_e2e.sh` (log `docs/superpowers/plans/2026-10-17-issue-81-e2e.log`). | Entregue, exceto o certificado de cliente por aparelho (ver STRIDE-S). Abertos no `PROGRESS.md` (#81): aparelho offline para sempre, JWT vivo até 15 min, API 24–29 e aparelho físico sem prova. | +| R3 | Vazamento de PII no dispositivo | **parcial** | Limpeza do aparelho pelo próprio ACS (`device_wipe_test.dart`); chave do SQLCipher no Keystore; limpeza remota pelo backoffice e rotação da chave por evento (#81, 2026-10-17): `admin.orderDeviceWipe`, `devices.pendingWipe`/`confirmWipe`, `RemoteWipeCoordinator`; prova no emulador API 36 em `./scripts/qa/acs_remote_wipe_e2e.sh` (log `docs/superpowers/plans/2026-10-17-issue-81-e2e.log`). | Certificado de cliente por aparelho (issue de continuação, a abrir); chave derivada de biometria, que o PRD pede (a chave do SQLCipher fica no Keystore sem amarração de autenticação, por decisão do `spec/lgpd_design.md` §5.1); aparelho offline nunca alcançado e JWT vivo até 15 min; API 24–29 e aparelho físico sem prova. | | R4 | Rejeição do ACS | **externo** | Nada no código. | NPS semanal e treinamento (produto + UBS). | | R5 | Performance do PostgreSQL | **parcial** | 46 índices na migração `20261014155304871`, incluindo `alerts_micro_area_status_idx` e `users_micro_area_idx`. | Particionamento por microárea não existe; sem teste de carga que mostre necessidade (M3.3). | | R6 | Dependência de SMS gateway | **parcial** | O ACS pode gerar quantas vezes quiser um convite QR de uso único para paciente da microárea dele (`backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart:21`), e `completeEnrollment` emite uma sessão do paciente (`onboarding_endpoint.dart:37,70`): é metade do fallback por QR. Nenhum provedor de SMS (RF01). | Entrada sem o ACS presente quando o SMS falha; biometria; múltiplos provedores de SMS. | @@ -231,7 +231,7 @@ Critério: **P0** toca invariante ou o núcleo do MVP (§6.1) sem critério atin | 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; 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~~ — resolvido na #80 (2026-10-17): `alerts.openForMicroArea` consultado a cada 30 s com o broker fora | R2, INV-03 | código | Risco de impacto 5 em rede 3G; antes, o ACS não tinha como listar alertas pendentes por HTTP. | -| 7 | P1 | ~~Remote wipe~~ e certificado de cliente por aparelho — o remote wipe e a rotação da chave foram resolvidos na #81 (2026-10-17); o certificado por aparelho segue na issue de continuação (a abrir) | R3, STRIDE-S, CRIPTO-APP-CACHE | código + infra | Risco de impacto 5; o certificado depende de uma lib X.509/CSR fora de `spec/stack.md`, de uma CA de aparelhos e de PKI de produção. | +| 7 | P1 | ~~Remote wipe~~ e certificado de cliente por aparelho — o remote wipe e a rotação da chave foram entregues na #81 (2026-10-17), mas R3 segue **parcial**; o certificado por aparelho segue na issue de continuação (a abrir) | R3, STRIDE-S, CRIPTO-APP-CACHE | código + infra | Risco de impacto 5; o certificado depende de uma lib X.509/CSR fora de `spec/stack.md`, de uma CA de aparelhos 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. | | 9 | P1 | Telemetria estruturada dos eventos do §1.3 | EVT-*, RNF02, H1, H2, H4, MOD-RELATORIOS | código | Sem os eventos, a North Star e três hipóteses não são mensuráveis no piloto. | | 10 | P2 | Rate limiting no Mosquitto e no Traefik | STRIDE-D | infra | DoS listado no STRIDE sem mitigação. | From 128162abd205a1604e3cc65b86892b42ccf0e38d Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 17:02:49 -0400 Subject: [PATCH 14/17] =?UTF-8?q?fix(acs):=20lista=20da=20micro=C3=A1rea?= =?UTF-8?q?=20e=20pull=20atrasados=20n=C3=A3o=20regravam=20o=20aparelho=20?= =?UTF-8?q?limpo=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O apagador da limpeza remota sela o cache da microárea e o pull de cada escopo antes de destruir o banco: a resposta que volta da rede depois não lê, não grava e não reabre o arquivo, então nenhuma chave nova é criada e nenhum nome ou condição crônica fica no aparelho que o servidor registra como limpo. O selo do cache abre no próximo login. --- apps/acs/lib/app/app.dart | 18 ++ .../lib/core/services/local_write_seal.dart | 29 +++ .../core/services/micro_area_directory.dart | 59 ++++- .../lib/core/services/visit_pull_service.dart | 19 +- apps/acs/test/micro_area_directory_test.dart | 51 +++++ .../test/remote_wipe_late_writes_test.dart | 210 ++++++++++++++++++ apps/acs/test/visit_pull_service_test.dart | 38 ++++ 7 files changed, 416 insertions(+), 8 deletions(-) create mode 100644 apps/acs/lib/core/services/local_write_seal.dart create mode 100644 apps/acs/test/remote_wipe_late_writes_test.dart diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index 2d12d48..615aada 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -642,13 +642,25 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i /// tokens de envio (sem revogar: o servidor já os revogou no pedido) e o id /// da instalação. D2: ao contrário do "Limpar este aparelho", nada aqui /// recusa por visita não enviada. + /// + /// Junto com as filas, SELA o pull de cada escopo e o cache da microárea + /// ([MicroAreaDirectory.seal]): a lista ou o pull que o painel pediu antes e + /// que só volte da rede depois da destruição reabriria o banco, criaria uma + /// chave nova e gravaria nomes e condições crônicas num aparelho que o + /// servidor registra como limpo. O selo do cache só abre no próximo login + /// ([_scopeForShell]). Future _eraseDevice() async { _wiping = true; try { for (final queue in {..._queues.values, for (final scope in _scopes.values) scope.queue}) { queue.seal(); } + for (final pull in {for (final scope in _scopes.values) scope.pullService, ?widget.visitPullService}) { + pull.seal(); + } + final cacheSealed = widget.microAreaDirectory?.seal(); await _tearDownForWipe(); + await cacheSealed; final owners = {..._queues.keys}; _queues.clear(); _scopes.clear(); @@ -762,6 +774,12 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i /// (gatilho "depois de abrir o painel"). Só `_openShell` chama o resolvedor. OwnerVisitScope _scopeForShell(AuthSession session) { final scope = _scopeFor(session); + // Login novo depois de uma limpeza remota: o cache e o pull voltam a + // gravar (o selo de [_eraseDevice] vale até aqui). Nunca no meio dela. + if (!_wiping) { + widget.microAreaDirectory?.unseal(); + scope.pullService.unseal(); + } _triggerFlush(); return scope; } diff --git a/apps/acs/lib/core/services/local_write_seal.dart b/apps/acs/lib/core/services/local_write_seal.dart new file mode 100644 index 0000000..2efe759 --- /dev/null +++ b/apps/acs/lib/core/services/local_write_seal.dart @@ -0,0 +1,29 @@ +/// Selo das gravações locais atrasadas durante a limpeza remota (#81). +/// +/// Uma operação que começa antes da limpeza e só volta da rede depois dela (a +/// lista da microárea, o pull de visitas) gravaria num banco recriado com +/// chave NOVA — nomes e condições crônicas no aparelho que o servidor registra +/// como limpo. [begin] dá uma ficha no começo da operação; [admits] diz, antes +/// de cada acesso ao disco, se ela ainda vale. +/// +/// [seal] invalida toda ficha emitida até ali e recusa novas até [unseal] (o +/// próximo login). Uma ficha emitida com o selo fechado nunca passa a valer: +/// [unseal] não ressuscita o que começou durante a limpeza. +class LocalWriteSeal { + int _epoch = 0; + bool _sealed = false; + + bool get isSealed => _sealed; + + /// `null` com o selo fechado: a operação não grava nada. + int? begin() => _sealed ? null : _epoch; + + bool admits(int? ticket) => ticket != null && !_sealed && ticket == _epoch; + + void seal() { + _sealed = true; + _epoch++; + } + + void unseal() => _sealed = false; +} diff --git a/apps/acs/lib/core/services/micro_area_directory.dart b/apps/acs/lib/core/services/micro_area_directory.dart index b54e24d..db35190 100644 --- a/apps/acs/lib/core/services/micro_area_directory.dart +++ b/apps/acs/lib/core/services/micro_area_directory.dart @@ -1,13 +1,19 @@ +import 'dart:async'; import 'dart:developer' as developer; import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; import 'package:sinalacs_acs/core/network/auth_session.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/services/local_write_seal.dart'; import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; /// Validade do cache da microárea (spec/lgpd_design.md §5.11). const microAreaCacheMaxAge = Duration(hours: 72); +/// Teto da espera, em [MicroAreaDirectory.seal], por uma gravação do cache já +/// em andamento: é E/S local, não rede. +const microAreaCacheSealWait = Duration(seconds: 5); + class MicroAreaSnapshot { const MicroAreaSnapshot({required this.patients, required this.fetchedAt, required this.fromCache}); @@ -42,6 +48,41 @@ class MicroAreaDirectory { final DateTime Function() _clock; final Duration maxAge; + final LocalWriteSeal _seal = LocalWriteSeal(); + + /// Gravações e leituras do cache em andamento (as que já passaram pelo selo). + final Set> _inFlight = >{}; + + /// Limpeza remota (#81): nenhuma carga em andamento ou futura toca no cache + /// — nem grava, nem lê, nem apaga, porque qualquer acesso reabriria o banco + /// e criaria uma chave nova depois do apagador — até [unseal]. Espera, com + /// teto, o acesso que já tinha começado, para o apagador não correr com ele. + Future seal() async { + _seal.seal(); + if (_inFlight.isEmpty) return; + try { + await Future.wait(_inFlight.toList()).timeout(microAreaCacheSealWait); + } catch (_) {} + } + + /// O próximo login depois da limpeza: o cache volta a valer para as cargas + /// que começarem daqui em diante. + void unseal() => _seal.unseal(); + + /// Roda [op] sobre o cache só se a ficha ainda vale, e a registra como em + /// andamento para [seal] esperar. `false`: o cache foi selado. + Future _acessarCache(int? ticket, Future Function() op) async { + if (!_seal.admits(ticket)) return false; + final running = op(); + _inFlight.add(running); + try { + await running; + } finally { + _inFlight.remove(running); + } + return true; + } + String? _owner() { final s = _session(); if (s == null || s.microAreaId == null) return null; @@ -49,13 +90,15 @@ class MicroAreaDirectory { } Future load() async { + final ticket = _seal.begin(); final owner = _owner(); try { final fresh = await _fetch(); final at = _clock().toUtc(); if (owner != null) { try { - await _store.write(owner: owner, patients: fresh, at: at); + // A resposta pode chegar depois da limpeza remota: aí não grava. + await _acessarCache(ticket, () => _store.write(owner: owner, patients: fresh, at: at)); } catch (error, stack) { // Gravar o cache é conforto; nunca derruba a lista que acabou de chegar. developer.log('não foi possível gravar o cache da microárea', @@ -65,27 +108,29 @@ class MicroAreaDirectory { return MicroAreaSnapshot(patients: fresh, fetchedAt: at, fromCache: false); } on BackendFailure catch (falha) { if (!falha.isRecoverable || owner == null) rethrow; - final guardado = await _lerSemFalhar(owner); + final guardado = await _lerSemFalhar(ticket, owner); if (guardado == null) rethrow; if (_clock().toUtc().difference(guardado.fetchedAt) > maxAge) { - await _apagarSemFalhar(); + await _apagarSemFalhar(ticket); rethrow; } return MicroAreaSnapshot(patients: guardado.patients, fetchedAt: guardado.fetchedAt, fromCache: true); } } - Future _lerSemFalhar(String owner) async { + Future _lerSemFalhar(int? ticket, String owner) async { + CachedMicroArea? lido; try { - return await _store.read(owner: owner); + await _acessarCache(ticket, () async => lido = await _store.read(owner: owner)); } catch (_) { return null; // cache ilegível = sem cache; a falha da rede é a que importa } + return lido; } - Future _apagarSemFalhar() async { + Future _apagarSemFalhar(int? ticket) async { try { - await _store.clear(); + await _acessarCache(ticket, _store.clear); } catch (_) {} } } diff --git a/apps/acs/lib/core/services/visit_pull_service.dart b/apps/acs/lib/core/services/visit_pull_service.dart index 1a46874..82dec58 100644 --- a/apps/acs/lib/core/services/visit_pull_service.dart +++ b/apps/acs/lib/core/services/visit_pull_service.dart @@ -1,5 +1,6 @@ import 'package:sinalacs_acs/core/database/sync_cursor_store.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/services/local_write_seal.dart'; import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; import 'package:sinalacs_client/sinalacs_client.dart'; @@ -41,6 +42,18 @@ class VisitPullService { List _lastPulled = const []; + final LocalWriteSeal _seal = LocalWriteSeal(); + + /// Limpeza remota (#81): um pull em voo que volte da rede depois do + /// apagador não lê a fila nem grava o cursor — os dois reabririam o banco e + /// criariam uma chave nova num aparelho já limpo. Pulls novos não começam + /// até [unseal]. + void seal() => _seal.seal(); + + /// O próximo login depois da limpeza (o gancho de teste usa o mesmo serviço + /// para qualquer dono; em produção cada dono ganha um novo). + void unseal() => _seal.unseal(); + /// Entradas do último [pullAndMerge] que não duplicam a fila offline local, /// na ordem em que o servidor as devolveu. Só para leitura pela UI — ver /// nota da classe. @@ -65,9 +78,13 @@ class VisitPullService { /// e não arrisca pular nada — diferente de gravar "agora" sem ter recebido /// confirmação nenhuma do servidor sobre até onde a microárea foi varrida. Future pullAndMerge() async { + final ticket = _seal.begin(); + if (!_seal.admits(ticket)) return; final since = await _cursorStore.read() ?? epoch; final entries = await _backend.pullVisits(since: since); + // Cada acesso ao disco depois da rede confere o selo antes de começar. + if (!_seal.admits(ticket)) return; final existingLocalIds = { for (final visit in await _localVisits.load()) visit.localId, }; @@ -84,7 +101,7 @@ class VisitPullService { (latest, syncAt) => latest == null || syncAt.isAfter(latest) ? syncAt : latest, ); - if (latestSyncAt != null) { + if (latestSyncAt != null && _seal.admits(ticket)) { await _cursorStore.write(latestSyncAt); } } diff --git a/apps/acs/test/micro_area_directory_test.dart b/apps/acs/test/micro_area_directory_test.dart index d485224..4237c89 100644 --- a/apps/acs/test/micro_area_directory_test.dart +++ b/apps/acs/test/micro_area_directory_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_acs/core/network/auth_session.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; @@ -127,4 +129,53 @@ void main() { await diretorio().load(); expect(store.patients, isEmpty); }); + + group('selo da limpeza remota (#81)', () { + test('lista que responde depois do selo não grava; o próximo login volta a gravar', () async { + final rede = Completer>(); + busca = () => rede.future; + final dir = diretorio(); + + final carga = dir.load(); + await dir.seal(); + rede.complete([_p(1)]); + final r = await carga; + + expect(r.patients, hasLength(1), reason: 'a lista fresca ainda chega a quem pediu'); + expect(store.owner, isNull, reason: 'mas nada é gravado no aparelho limpo'); + + dir.unseal(); + busca = () async => [_p(2)]; + await dir.load(); + expect(store.owner, 'u1|m1'); + }); + + test('carga que começa selada não grava nem depois de unseal', () async { + final rede = Completer>(); + busca = () => rede.future; + final dir = diretorio(); + await dir.seal(); + + final carga = dir.load(); + dir.unseal(); + rede.complete([_p(1)]); + await carga; + + expect(store.owner, isNull); + }); + + test('falha recuperável depois do selo não lê nem apaga o cache', () async { + final dir = diretorio(); + await dir.load(); + final rede = Completer>(); + busca = () => rede.future; + + final carga = dir.load(); + await dir.seal(); + rede.completeError(const BackendFailure('Sem conexão.')); + + await expectLater(carga, throwsA(isA())); + expect(store.clears, 0); + }); + }); } diff --git a/apps/acs/test/remote_wipe_late_writes_test.dart b/apps/acs/test/remote_wipe_late_writes_test.dart new file mode 100644 index 0000000..5daaf1d --- /dev/null +++ b/apps/acs/test/remote_wipe_late_writes_test.dart @@ -0,0 +1,210 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; +import 'package:sinalacs_acs/core/security/upload_token_store.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; +import 'package:sinalacs_acs/core/services/remote_wipe.dart'; +import 'package:sinalacs_client/sinalacs_client.dart'; + +import 'support/fakes.dart'; +import 'support/layout_harness.dart' show assentar; + +/// Chave em memória que conta quantas vezes uma chave NOVA foi criada: uma +/// escrita atrasada depois da limpeza remota criaria (e guardaria) uma. +class _ChaveContada extends InMemoryDatabaseKeyStore { + _ChaveContada() : super(initialKey: 'a' * 64); + + bool temChave = true; + int criadas = 0; + + @override + Future readOrCreate() async { + if (!temChave) criadas++; + temChave = true; + return super.readOrCreate(); + } + + @override + Future delete() async { + temChave = false; + await super.delete(); + } +} + +/// Limpeza remota (#81, I1): a lista da microárea e o pull que respondem +/// DEPOIS do apagador não recriam o banco, nem a chave, nem gravam nomes e +/// condições crônicas no aparelho que o servidor registra como limpo. E o +/// próximo login volta a ter o cache. Banco real (FFI), dados sintéticos. +void main() { + const dbName = 'remote_wipe_late_writes_test.db'; + const acsA = 'acs-a'; + const ordem = 'ordem-tardia'; + const tabelas = ['offline_visits', 'sync_cursor', 'micro_area_cache', 'micro_area_cache_meta']; + + late _ChaveContada chave; + late SqlCipherVisitStorage storage; + late MicroAreaCacheStore cache; + late FakeAcsBackend backend; + late MemoryDeviceIdStore deviceIds; + late MemoryWipeJournal journal; + Completer? listaGate; + + Future io(WidgetTester tester, Future Function() op) async => (await tester.runAsync(op)) as T; + + Future arquivoExiste(WidgetTester tester) => + io(tester, () async => File(await EncryptedLocalDatabase.pathFor(dbName)).exists()); + + Future> contarTabelas(WidgetTester tester) => io(tester, () async { + final db = await storage.database.open(); + return { + for (final tabela in tabelas) + tabela: ((await db.rawQuery('SELECT COUNT(*) AS n FROM $tabela')).single['n']! as int), + }; + }); + + setUp(() async { + await EncryptedLocalDatabase.deleteDatabaseFile(dbName); + chave = _ChaveContada(); + storage = SqlCipherVisitStorage(keyStore: chave, databaseName: dbName, allowUnencryptedForTesting: true); + cache = MicroAreaCacheStore(keyStore: chave, databaseName: dbName, allowUnencryptedForTesting: true); + deviceIds = MemoryDeviceIdStore('00000000-0000-4000-8000-0000000000d1'); + backend = FakeAcsBackend() + ..nextUserId = acsA + ..deviceIds = deviceIds + ..pullEntries = [ + VisitSyncEntry( + localId: 'remota-1', + patientId: seedPatientId, + scheduledAt: DateTime.utc(2026, 10, 3, 9), + status: 'realizada', + riskLevelBefore: RiskLevel.green, + notes: const {}, + version: 1, + syncAt: DateTime.utc(2026, 10, 3, 10), + arrivalMethod: ArrivalMethod.manual, + ), + ]; + journal = MemoryWipeJournal(); + listaGate = null; + }); + + tearDown(() async { + await storage.close(); + await EncryptedLocalDatabase.deleteDatabaseFile(dbName); + }); + + Future abrirApp(WidgetTester tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: storage, + microAreaDirectory: MicroAreaDirectory( + fetch: () async { + await listaGate?.future; + return [ + MicroAreaPatient( + patientId: syntheticPatientId(5), + name: 'Paciente Sintético', + isChronic: true, + chronicConditions: const ['condição sintética'], + ), + ]; + }, + session: () => backend.session, + store: cache, + ), + uploadTokens: MemoryUploadTokenStore(), + deviceIds: deviceIds, + wipeJournal: journal, + biometricGate: FakeBiometricGate(), + feedBuilder: (q) => FakeAlertFeed(q), + )); + await assentar(tester); + } + + Future entrar(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + final botao = find.byKey(const Key('login_button')); + await tester.ensureVisible(botao); + await tester.pump(); + await tester.tap(botao); + await assentar(tester); + expect(find.text('Painel operacional'), findsOneWidget); + } + + testWidgets('lista da microárea e pull que respondem depois do apagador não recriam banco nem chave', + (tester) async { + await abrirApp(tester); + await entrar(tester); + final carregado = await contarTabelas(tester); + expect(carregado['micro_area_cache'], 1, reason: 'a abertura do painel gravou o cache'); + expect(carregado['sync_cursor'], 1, reason: 'e o cursor do pull'); + + // Volta do segundo plano: o painel atualiza a área (presa na rede) e, em + // paralelo, a consulta encontra o pedido de limpeza. + listaGate = Completer(); + backend.pullGate = Completer(); + backend.pendingWipeOrderId = ordem; + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await assentar(tester); + await assentar(tester); + + expect(find.byKey(const Key('device_wiped')), findsOneWidget); + expect(backend.confirmedWipes, hasLength(1)); + expect(await arquivoExiste(tester), isFalse, reason: 'o apagador levou o arquivo'); + expect(chave.temChave, isFalse, reason: 'e a chave'); + + // A rede responde só agora, depois da limpeza. + listaGate!.complete(); + backend.pullGate!.complete(); + await assentar(tester); + await assentar(tester); + + expect(chave.criadas, 0, reason: 'nenhuma escrita atrasada criou uma chave nova'); + expect(chave.temChave, isFalse); + expect(await arquivoExiste(tester), isFalse, reason: 'nem um arquivo novo com nomes e condições crônicas'); + expect(backend.acknowledgedAlertIds, isEmpty); + expect(backend.resolvedAlertIds, isEmpty); + }); + + testWidgets('depois de Entrar de novo o cache e o cursor voltam a ser gravados', (tester) async { + await abrirApp(tester); + await entrar(tester); + + listaGate = Completer(); + backend.pullGate = Completer(); + backend.pendingWipeOrderId = ordem; + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await assentar(tester); + listaGate!.complete(); + backend.pullGate!.complete(); + await assentar(tester); + expect(find.byKey(const Key('device_wiped')), findsOneWidget); + expect(await arquivoExiste(tester), isFalse); + + listaGate = null; + backend.pullGate = null; + backend.pendingWipeOrderId = null; // o id novo não tem pedido + final botao = find.byKey(const Key('device_wiped_login')); + await tester.ensureVisible(botao); + await tester.tap(botao); + await assentar(tester); + await entrar(tester); + await assentar(tester); + + final depois = await contarTabelas(tester); + expect(depois['micro_area_cache'], 1, reason: 'o login novo volta a ter o cache da microárea'); + expect(depois['sync_cursor'], 1, reason: 'e o cursor do pull'); + expect(chave.criadas, 1, reason: 'uma chave nova, criada pelo login novo'); + }); +} diff --git a/apps/acs/test/visit_pull_service_test.dart b/apps/acs/test/visit_pull_service_test.dart index dfd031b..d0c5fa4 100644 --- a/apps/acs/test/visit_pull_service_test.dart +++ b/apps/acs/test/visit_pull_service_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_acs/core/database/encrypted_database.dart'; import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; @@ -223,4 +225,40 @@ void main() { expect(service.lastPulled.map((e) => e.localId), ['local-a1']); }); }); + + test('selo da limpeza remota (#81): pull que volta da rede depois do selo não grava cursor nem lê a fila', () async { + backend.pullEntries = [entry(localId: 'remota-1', syncAt: DateTime.utc(2021, 1, 2))]; + backend.pullGate = Completer(); + final local = _LoadCounter(); + final service = VisitPullService(backend: backend, cursorStore: cursorStore, localVisits: local); + + final pull = service.pullAndMerge(); + await pumpEventQueue(); + service.seal(); + backend.pullGate!.complete(); + await pull; + + expect(local.loads, 0); + expect(await cursorStore.read(), isNull); + + // Selado, um pull novo nem sai. + final antes = backend.pullSinceCalls.length; + await service.pullAndMerge(); + expect(backend.pullSinceCalls.length, antes); + + backend.pullGate = null; + service.unseal(); + await service.pullAndMerge(); + expect(await cursorStore.read(), DateTime.utc(2021, 1, 2)); + }); +} + +class _LoadCounter extends InMemoryVisitStore { + int loads = 0; + + @override + Future> load() { + loads++; + return super.load(); + } } From 09851c10ed75fd71dd3f9b1b15aea5a7fa78d118 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 17:06:43 -0400 Subject: [PATCH 15/17] =?UTF-8?q?fix(acs):=20di=C3=A1rio=20preso=20em=20er?= =?UTF-8?q?asing=20n=C3=A3o=20repete=20a=20limpeza=20e=20a=20confirma?= =?UTF-8?q?=C3=A7=C3=A3o=20da=20partida=20tem=20teto=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Com o diário em erasing, a limpeza conta como feita quando o pedido já foi apagado nesta execução ou quando o id da instalação já é outro (o apagador o troca no último passo): a retomada depois de "Entrar de novo" só reenvia a confirmação e não leva as visitas do login novo. Na partida, só a confirmação ganha o teto de startupWipeCheckTimeout; a limpeza segue sem teto, antes de qualquer uso do banco. --- apps/acs/lib/app/app.dart | 4 +- apps/acs/lib/core/services/remote_wipe.dart | 46 ++++++++-- apps/acs/test/remote_wipe_test.dart | 98 ++++++++++++++++++++- apps/acs/test/remote_wipe_ui_test.dart | 84 ++++++++++++++++++ apps/acs/test/support/fakes.dart | 5 ++ 5 files changed, 224 insertions(+), 13 deletions(-) diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index 615aada..b88e29c 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -565,7 +565,9 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i Future _startRemoteWipe() async { final wipe = _remoteWipe!; try { - await wipe.resumePending(); + // A limpeza pendente termina aqui, sem teto, antes de qualquer uso do + // banco; só a confirmação tem teto (servidor mudo não segura o login). + await wipe.resumePending(confirmTimeout: startupWipeCheckTimeout); } catch (_) { // O apagador falhou: o diário segue em `erasing` e a próxima retomada, // volta da rede ou partida tenta de novo. Nada do erro vai ao log. diff --git a/apps/acs/lib/core/services/remote_wipe.dart b/apps/acs/lib/core/services/remote_wipe.dart index eb67612..cf829bc 100644 --- a/apps/acs/lib/core/services/remote_wipe.dart +++ b/apps/acs/lib/core/services/remote_wipe.dart @@ -135,10 +135,15 @@ class RemoteWipeCoordinator { /// confirma; `erased` só reenvia a confirmação. Sem diário, não faz nada. /// Uma falha do apagador sobe (o diário continua em `erasing`, e a próxima /// partida tenta de novo); a da confirmação, não. - Future resumePending() => _single(() async { + /// + /// [confirmTimeout] limita SÓ a espera pela confirmação (a partida usa: um + /// servidor que aceita a conexão e não responde não segura o login atrás da + /// espera neutra). A limpeza em si nunca tem teto: termina antes de qualquer + /// uso do banco. A confirmação que estoura o teto segue no diário. + Future resumePending({Duration? confirmTimeout}) => _single(() async { final entry = await _readJournal(); if (entry == null) return; - await _run(entry.orderId, entry.deviceId, entry.phase); + await _run(entry.orderId, entry.deviceId, entry.phase, confirmTimeout: confirmTimeout); }); /// Pergunta ao servidor se há limpeza pedida para este aparelho e, se há, @@ -188,7 +193,16 @@ class RemoteWipeCoordinator { return _inFlight = flight; } - Future _run(String orderId, String deviceId, WipePhase phase) async { + Future _run(String orderId, String deviceId, WipePhase phase, {Duration? confirmTimeout}) async { + if (phase == WipePhase.erasing && await _alreadyErased(orderId, deviceId)) { + // O diário ficou em `erasing` só porque gravar `erased` falhou: o + // aparelho já está limpo. Apagar de novo levaria o que o login novo + // registrou depois ("Entrar de novo" e visitas sem rede). + phase = WipePhase.erased; + try { + await _journal.write(orderId, deviceId, WipePhase.erased); + } catch (_) {} + } if (phase == WipePhase.erasing) { // D7: o diário vem ANTES de qualquer exclusão. Se ele não grava, nada // é apagado e o erro sobe: o próximo gatilho tenta de novo. @@ -205,14 +219,30 @@ class RemoteWipeCoordinator { } else { _erasedOrders.add(orderId); } - await _confirm(orderId, deviceId); + await _confirm(orderId, deviceId, timeout: confirmTimeout); + } + + /// `true` quando a limpeza de [orderId] já foi até o fim, ainda que o diário + /// diga `erasing`: nesta execução ([_erasedOrders]), ou numa anterior — o + /// apagador troca o id da instalação no ÚLTIMO passo, então um id diferente + /// do [deviceId] do diário prova que todos os outros passos terminaram. Sem + /// conseguir ler o id, refaz a limpeza (idempotente): o lado seguro. + Future _alreadyErased(String orderId, String deviceId) async { + if (_erasedOrders.contains(orderId)) return true; + try { + return await _deviceIds.readOrCreate() != deviceId; + } catch (_) { + return false; + } } - /// Confirmação no servidor. Sem rede, o diário fica em `erased` para o - /// próximo gatilho; só a resposta do servidor apaga o diário. - Future _confirm(String orderId, String deviceId) async { + /// Confirmação no servidor. Sem rede (ou além de [timeout]), o diário fica + /// em `erased` para o próximo gatilho; só a resposta do servidor apaga o + /// diário. + Future _confirm(String orderId, String deviceId, {Duration? timeout}) async { try { - await _backend.confirmDeviceWipe(deviceId, orderId); + final sent = _backend.confirmDeviceWipe(deviceId, orderId); + await (timeout == null ? sent : sent.timeout(timeout)); } catch (_) { return; } diff --git a/apps/acs/test/remote_wipe_test.dart b/apps/acs/test/remote_wipe_test.dart index 0bbe369..9847fc2 100644 --- a/apps/acs/test/remote_wipe_test.dart +++ b/apps/acs/test/remote_wipe_test.dart @@ -17,7 +17,7 @@ void main() { const idAntigo = '00000000-0000-4000-8000-0000000000d1'; late FakeAcsBackend backend; - late MemoryDeviceIdStore deviceIds; + late DeviceIdStore deviceIds; late MemoryWipeJournal journal; late List eventos; late int eraseCalls; @@ -158,10 +158,9 @@ void main() { }); test('diário em erasing termina a limpeza na partida antes de abrir o banco', () async { - // O processo morreu no meio da limpeza: o id da instalação já pode ser - // outro, mas a confirmação vai com o do diário. + // O processo morreu no meio da limpeza, antes de trocar o id da + // instalação (o último passo do apagador). await journal.write(orderId, idAntigo, WipePhase.erasing); - deviceIds = MemoryDeviceIdStore('00000000-0000-4000-8000-0000000000d2'); await novo().resumePending(); @@ -170,6 +169,75 @@ void main() { expect(await journal.read(), isNull); }); + test('diário preso em erasing com o id já trocado: a limpeza terminou, só confirma (I2)', () async { + // Outra execução: o apagador foi até o fim (o id é outro), mas o diário + // não chegou a `erased`. Apagar de novo levaria as visitas do login novo. + await journal.write(orderId, idAntigo, WipePhase.erasing); + deviceIds = MemoryDeviceIdStore('00000000-0000-4000-8000-0000000000d2'); + + await novo().resumePending(); + + expect(eraseCalls, 0); + expect(wipedCalls, 0, reason: 'a tela de limpeza já foi mostrada naquela execução'); + expect(backend.confirmedWipes, [(deviceId: idAntigo, orderId: orderId)]); + expect(await journal.read(), isNull); + }); + + test('diário preso em erasing na MESMA execução: resumePending não apaga de novo (I2)', () async { + final diario = _DiarioQueFalhaEmErased(); + journal = diario; + backend.confirmWipeFailure = const BackendFailure('Sem conexão com o servidor.'); + final coordinator = novo(); + + await coordinator.apply(orderId); + expect(eraseCalls, 1); + expect((await journal.read())?.phase, WipePhase.erasing, reason: 'gravar erased falhou e a confirmação também'); + + // Retomadas e voltas da rede depois de "Entrar de novo". + await coordinator.resumePending(); + await coordinator.resumePending(); + + expect(eraseCalls, 1); + expect(wipedCalls, 1); + expect(eventos.where((e) => e == 'confirm'), hasLength(3), reason: 'só a confirmação é reenviada'); + expect((await journal.read())?.phase, WipePhase.erased, reason: 'o diário passa a dizer o que aconteceu'); + }); + + test('diário preso em erasing com o id ilegível: refaz a limpeza (lado seguro)', () async { + await journal.write(orderId, idAntigo, WipePhase.erasing); + deviceIds = _IdQueFalha(); + + await novo().resumePending(); + + expect(eraseCalls, 1); + }); + + test('confirmTimeout limita só a confirmação: servidor mudo não prende a retomada (M1)', () async { + await journal.write(orderId, idAntigo, WipePhase.erased); + backend.confirmWipeGate = Completer(); + + await novo().resumePending(confirmTimeout: const Duration(milliseconds: 50)); + + expect(eraseCalls, 0); + expect(await journal.read(), (orderId: orderId, deviceId: idAntigo, phase: WipePhase.erased), + reason: 'a confirmação sem resposta segue no diário'); + backend.confirmWipeGate!.complete(); + }); + + test('confirmTimeout não corta a limpeza em si (M1)', () async { + await journal.write(orderId, idAntigo, WipePhase.erasing); + eraseGate = Completer(); + var terminou = false; + final retomada = novo().resumePending(confirmTimeout: const Duration(milliseconds: 10)).then((_) => terminou = true); + + await Future.delayed(const Duration(milliseconds: 50)); + expect(terminou, isFalse, reason: 'a retomada espera o apagador, sem teto'); + eraseGate!.complete(); + await retomada; + expect(eraseCalls, 1); + expect(await journal.read(), isNull); + }); + test('resumePending sem diário não faz nada', () async { await novo().resumePending(); @@ -261,6 +329,28 @@ void main() { }); } +/// Diário cuja PRIMEIRA gravação de `erased` falha (disco cheio, por exemplo). +class _DiarioQueFalhaEmErased extends MemoryWipeJournal { + bool _falhou = false; + + @override + Future write(String orderId, String deviceId, WipePhase phase) async { + if (phase == WipePhase.erased && !_falhou) { + _falhou = true; + throw StateError('diário não gravado'); + } + await super.write(orderId, deviceId, phase); + } +} + +class _IdQueFalha implements DeviceIdStore { + @override + Future readOrCreate() async => throw StateError('Keystore indisponível'); + + @override + Future delete() async {} +} + class _MapStorage implements FlutterSecureStorage { final Map data = {}; diff --git a/apps/acs/test/remote_wipe_ui_test.dart b/apps/acs/test/remote_wipe_ui_test.dart index 54d95b6..117d30e 100644 --- a/apps/acs/test/remote_wipe_ui_test.dart +++ b/apps/acs/test/remote_wipe_ui_test.dart @@ -40,6 +40,20 @@ class _CountingStorage implements VisitStorage { } } +/// Diário cuja PRIMEIRA gravação de `erased` falha (disco cheio, por exemplo). +class _DiarioQueFalhaEmErased extends MemoryWipeJournal { + bool _falhou = false; + + @override + Future write(String orderId, String deviceId, WipePhase phase) async { + if (phase == WipePhase.erased && !_falhou) { + _falhou = true; + throw StateError('diário não gravado'); + } + await super.write(orderId, deviceId, phase); + } +} + /// Limpeza remota no app (#81, Task 5): partida, retomada, volta da rede e /// renovação levam à mesma tela `device_wiped`, fora do bloqueio. Ids /// sintéticos. @@ -320,6 +334,76 @@ void main() { expect(storage.destroyCalls, 1, reason: 'reenviar a confirmação não apaga de novo'); }); + testWidgets('servidor mudo na confirmação da partida não segura o login além do teto (M1)', (tester) async { + // Aparelho já limpo numa execução anterior; falta só a confirmação, e o + // servidor aceita a conexão mas nunca responde. + await journal.write(ordem, idAntigo, WipePhase.erased); + backend.confirmWipeGate = Completer(); + + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: storage, + uploadTokens: tokens, + deviceIds: deviceIds, + wipeJournal: journal, + connectivityChanges: rede.stream, + biometricGate: FakeBiometricGate(), + feedBuilder: (AlertQueue q) => FakeAlertFeed(q), + )); + await tester.pump(); + expect(find.byType(LoginScreen), findsNothing, reason: 'a espera neutra vem antes do diário'); + + // Teto da confirmação + teto da consulta, com folga; bem abaixo dos ~20 s + // do tempo de conexão do cliente real. + await tester.pump(startupWipeCheckTimeout); + await tester.pump(startupWipeCheckTimeout); + await tester.pump(const Duration(seconds: 1)); + + expect(find.byType(LoginScreen), findsOneWidget, reason: 'o login sai da espera mesmo sem resposta'); + expect((await journal.read())?.phase, WipePhase.erased, reason: 'a confirmação segue no diário'); + expect(storage.destroyCalls, 0, reason: 'erased não apaga de novo'); + backend.confirmWipeGate!.complete(); + await assentar(tester); + }); + + testWidgets('diário preso em erasing depois de Entrar de novo: a retomada não apaga a visita nova (I2)', + (tester) async { + journal = _DiarioQueFalhaEmErased(); + backend.pendingWipeOrderId = ordem; + backend.confirmWipeFailure = const BackendFailure('Sem conexão com o servidor.'); + await abrirApp(tester); + esperarTelaDeLimpeza(); + expect((await journal.read())?.phase, WipePhase.erasing, reason: 'gravar erased falhou; a confirmação também'); + + backend.pendingWipeOrderId = null; // o id novo não tem pedido + final botao = find.byKey(const Key('device_wiped_login')); + await tester.ensureVisible(botao); + await tester.tap(botao); + await assentar(tester); + await entrar(tester); + // Uma visita registrada sem rede pelo login novo. + await storage.inner.forOwner(seedAcsId).save([ + OfflineVisitRecord(localId: 'v-depois', patientId: seedPatientId, risk: 'green', status: 'PENDENTE'), + ]); + + for (final estado in [AppLifecycleState.inactive, AppLifecycleState.hidden, AppLifecycleState.paused]) { + tester.binding.handleAppLifecycleStateChanged(estado); + } + for (final estado in [AppLifecycleState.hidden, AppLifecycleState.inactive, AppLifecycleState.resumed]) { + tester.binding.handleAppLifecycleStateChanged(estado); + } + await assentar(tester); + rede.add(false); + rede.add(true); + await assentar(tester); + + expect(storage.destroyCalls, 1, reason: 'a retomada não refaz a limpeza'); + expect(await storage.inner.forOwner(seedAcsId).load(), hasLength(1), reason: 'a visita nova continua no aparelho'); + expect(find.byKey(const Key('device_wiped')), findsNothing); + expect(find.text('Painel operacional'), findsOneWidget); + expect((await journal.read())?.phase, WipePhase.erased); + }); + testWidgets('fonte 200% em 320x640 sem estouro na device_wiped', (tester) async { addTearDown(tester.view.reset); addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); diff --git a/apps/acs/test/support/fakes.dart b/apps/acs/test/support/fakes.dart index e1988e9..0c59ba6 100644 --- a/apps/acs/test/support/fakes.dart +++ b/apps/acs/test/support/fakes.dart @@ -522,9 +522,14 @@ class FakeAcsBackend implements AcsBackend { /// Confirmações que chegaram ao "servidor" (só as que não falharam). final List<({String deviceId, String orderId})> confirmedWipes = <({String deviceId, String orderId})>[]; + /// Se definido, `confirmDeviceWipe` espera por ele (servidor que aceita a + /// conexão e não responde). + Completer? confirmWipeGate; + @override Future confirmDeviceWipe(String deviceId, String orderId) async { onConfirmDeviceWipe?.call(); + await confirmWipeGate?.future; final failure = confirmWipeFailure; if (failure != null) throw failure; confirmedWipes.add((deviceId: deviceId, orderId: orderId)); From 7e93e61befd6a568dfa2982f9f423eed7e8af84c Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 17:07:18 -0400 Subject: [PATCH 16/17] =?UTF-8?q?docs:=20registra=20o=20selo=20das=20escri?= =?UTF-8?q?tas=20tardias,=20o=20di=C3=A1rio=20preso=20e=20os=20limites=20D?= =?UTF-8?q?5=20e=20de=20taxa=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 8 +++++--- apps/CLAUDE.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 0ff4996..330895f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1704,13 +1704,15 @@ Backlog priorizado na seção final do relatório; conferência por `python3 scr - Aberto: - Aparelho offline para sempre não é alcançável (inerente ao modelo, D3). - O JWT vivo vale até 15 min depois do pedido; a limpeza só chega na renovação seguinte ou numa consulta do app. - - A checagem da partida do ACS espera até 5 s pela consulta `pendingWipe` quando o servidor está mudo, antes de mostrar o login. + - A partida do ACS com o servidor mudo espera até 5 s pela confirmação pendente do diário (`resumePending(confirmTimeout:)`; a limpeza em si não tem teto e termina antes de qualquer uso do banco) e mais até 5 s pela consulta `pendingWipe`, antes de mostrar o login: até ~10 s, no lugar dos ~25 s de antes (`remote_wipe_ui_test.dart`, "servidor mudo na confirmação da partida"). - Certificado de cliente MQTT por aparelho (D8, STRIDE-S): issue de continuação (a abrir); rascunho em `.superpowers/sdd/2026-10-17-issue-81-wipe-remoto-e-rotacao-de-chave/followup-issue.md`. - R3 do PRD segue **parcial** (`docs/auditoria-prd`): faltam também a chave derivada de biometria que o PRD pede (a do SQLCipher fica no Keystore sem amarração, §5.1). - Prova só na API 36 do emulador; API 24–29 e aparelho físico sem prova. - `pendingWipe`/`confirmWipe` são públicos: quem tem o `deviceId` pode carimbar entregue/confirmado sem apagar nada (o acesso segue bloqueado, porque o id antigo fica marcado para sempre). "Aparelho limpo" no admin é a afirmação do aparelho, não prova. - - O diário é de uma vaga só: um pedido mais novo sobrescreve a confirmação pendente de um mais antigo (o `wipe_confirmed` do antigo se perde). Se a gravação de `erased` e a confirmação falharem juntas, a partida seguinte vê `erasing` e apaga de novo (raro; pode atingir visitas de um login novo). - - Escritas tardias do cache da microárea ou do cursor de `pull` depois da limpeza remota não estão provadas impossíveis: a desmontagem não congela a área. No e2e o arquivo não foi recriado, numa execução só. + - Os dois endpoints públicos não têm limite de taxa: cada chamada é uma consulta ao banco, sem autenticação, como os demais endpoints públicos do backend. Um limite por IP/`deviceId` fica para quando houver um para todos. + - D5 tem consequência entre UBS: o pedido de um coordenador revoga os tokens e apaga as visitas de **todos** os donos daquele `deviceId`, inclusive ACS de fora da UBS dele que tenham usado o mesmo aparelho. É do desenho (a revogação é do aparelho, não da conta), mas o diálogo de confirmação do admin não diz isso; o texto foi fixado pelo plano e não mudou. + - O diário é de uma vaga só: um pedido mais novo sobrescreve a confirmação pendente de um mais antigo (o `wipe_confirmed` do antigo se perde). Se a gravação de `erased` e a confirmação falharem juntas, o diário fica em `erasing`, mas a retomada não apaga de novo: o pedido já apagado nesta execução, ou um `deviceId` diferente do diário (o apagador o troca no último passo), contam como limpeza feita, e só a confirmação é reenviada (`remote_wipe_test.dart`, `remote_wipe_ui_test.dart`, "diário preso em erasing"). + - Escritas tardias do cache da microárea e do cursor de `pull`: o apagador sela `MicroAreaDirectory` e o `VisitPullService` de cada escopo (`LocalWriteSeal`) antes de destruir o banco, e espera (até 5 s) a gravação do cache que já tinha começado; a resposta que volta da rede depois não lê, não grava e não reabre o arquivo, e o selo do cache só abre no próximo login (`remote_wipe_late_writes_test.dart`, banco real FFI: nenhuma chave nova, arquivo ausente, e o cache volta a gravar depois de "Entrar de novo"). Fica aberto: `MicroAreaCacheStore` tem a própria instância de `SecureStorageDatabaseKeyStore`, e no primeiro uso depois de uma destruição (ou da instalação) o `readOrCreate` dele pode correr com o do `VisitDatabase` e gerar duas chaves; na prática a lista vem da rede antes de gravar, mas nada garante a ordem. - Teste de dispositivo que já falhava antes desta branch (medido na base dela): "banco v6 ... migra para v7" em `apps/acs/integration_test/encrypted_storage_test.dart` espera `user_version` 7 e o schema é 8. - O motivo escolhido não aparece no histórico do app admin (só no servidor); se a recarga da lista falha depois de um pedido bem-sucedido, a tela mostra a falha genérica. - Conferidor `scripts/qa/cobertura_prd.py --final` falha por uma linha do RNF04 sem negrito no status; falhava igual com as mudanças desta issue guardadas (medido na base da branch). diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 039ef41..4dde212 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -38,7 +38,7 @@ The ACS login is institutional now (RF07): `_enter` in `apps/acs/lib/app/app.dar **ACS: conflito de visita (#79, 2026-10-16).** O conflito sai da retentativa (D3): `VisitSyncResult.serverVisit` traz a cópia do servidor, `OfflineVisitQueue` guarda a visita em `_conflicts` com `conflictServerVersion`/`conflictServer` (schema v8, só `ADD COLUMN`), `resolveKeepLocal` (também usado por "Editar e enviar", com `outcome`/`notes`) chama `visits.resolveConflict` e `resolveKeepServer` só apaga a cópia local. Tela `resolve_conflicts` (chaves `conflict_*`). Detalhe e provas: `PROGRESS.md`, seção "Resolução de conflito de visita (#79)". -**ACS: limpeza remota (#81, 2026-10-17).** `RemoteWipeCoordinator` (`core/services/remote_wipe.dart`) aplica o pedido do backoffice com um só voo (`apply`, `resumePending`, `check`): grava o `WipeJournal` (`shared_preferences`, só `orderId` e `deviceId` antigo, fase `erasing`/`erased`) **antes** de apagar, apaga, grava `erased`, confirma por `devices.confirmWipe` e só então limpa o diário; `erasing` refaz a limpeza (idempotente) na partida, `erased` só reenvia a confirmação. Gatilhos: partida (a raiz espera `resumePending` e `check`, teto `startupWipeCheckTimeout` = 5 s com o servidor mudo), retomada do segundo plano, volta da rede e `DeviceWipeRequiredException` da renovação/login/envio diferido (`DeviceWipeRequiredFailure`, não irrecuperável como sessão expirada). O `DeviceEraser` de `app.dart` desmonta a interface (o painel fecha MQTT, reserva HTTP e timers), espera até 5 s um `sync` em voo, descarta as filas em RAM sem gravar, esquece a sessão local, chama `VisitDatabase.destroy()` (fecha, apaga arquivo, sidecars e `.recuperado`, apaga a chave do SQLCipher: é a rotação por evento, D6; repetível e exclusivo contra `open` concorrente), apaga os tokens de envio sem `revokeUploadToken` e o `deviceId` (`DeviceIdStore.delete`). Depois mostra `device_wiped` (sem `AppLockGate`) e "Entrar de novo" abre um login com `deviceId` novo. O wipe remoto ignora o bloqueio de visitas não enviadas (D2); "Limpar este aparelho" local continua recusando com `WipeBlocked` e agora também troca a chave. Nada chama `acknowledge`/`resolve` (INV-03). Prova: `./scripts/qa/acs_remote_wipe_e2e.sh` (emulador API 36, `integration_test/remote_wipe_e2e.dart`). +**ACS: limpeza remota (#81, 2026-10-17).** `RemoteWipeCoordinator` (`core/services/remote_wipe.dart`) aplica o pedido do backoffice com um só voo (`apply`, `resumePending`, `check`): grava o `WipeJournal` (`shared_preferences`, só `orderId` e `deviceId` antigo, fase `erasing`/`erased`) **antes** de apagar, apaga, grava `erased`, confirma por `devices.confirmWipe` e só então limpa o diário; `erasing` refaz a limpeza (idempotente) na partida — salvo quando ela já terminou (pedido apagado nesta execução, ou `deviceId` atual diferente do diário: o apagador o troca no último passo), que conta como `erased` —, `erased` só reenvia a confirmação. Gatilhos: partida (a raiz espera `resumePending` e `check`; a confirmação e a consulta têm, cada uma, o teto `startupWipeCheckTimeout` = 5 s com o servidor mudo, a limpeza não), retomada do segundo plano, volta da rede e `DeviceWipeRequiredException` da renovação/login/envio diferido (`DeviceWipeRequiredFailure`, não irrecuperável como sessão expirada). O `DeviceEraser` de `app.dart` sela as filas, o pull de cada escopo e o cache da microárea (`LocalWriteSeal`: resposta da rede que chega depois não grava nem reabre o banco; o selo do cache abre no próximo login, em `_scopeForShell`), desmonta a interface (o painel fecha MQTT, reserva HTTP e timers), espera até 5 s um `sync` em voo, descarta as filas em RAM sem gravar, esquece a sessão local, chama `VisitDatabase.destroy()` (fecha, apaga arquivo, sidecars e `.recuperado`, apaga a chave do SQLCipher: é a rotação por evento, D6; repetível e exclusivo contra `open` concorrente), apaga os tokens de envio sem `revokeUploadToken` e o `deviceId` (`DeviceIdStore.delete`). Depois mostra `device_wiped` (sem `AppLockGate`) e "Entrar de novo" abre um login com `deviceId` novo. O wipe remoto ignora o bloqueio de visitas não enviadas (D2); "Limpar este aparelho" local continua recusando com `WipeBlocked` e agora também troca a chave. Nada chama `acknowledge`/`resolve` (INV-03). Prova: `./scripts/qa/acs_remote_wipe_e2e.sh` (emulador API 36, `integration_test/remote_wipe_e2e.dart`). **ACS: reserva por HTTP dos alertas (#80, 2026-10-17).** `AlertHttpFallback` (`core/services/alert_http_fallback.dart`, sem `Timer`) chama `AcsBackend.openAlerts` (`alerts.openForMicroArea`) e funde em `AlertQueue.mergeFromServer`, que nunca remove cartão, ignora outra microárea, nunca desfaz ACK, conclusão ou reforço (só os ganha: um ACK feito em outro aparelho promove o cartão) e só notifica quando algo mudou. Queda do broker depois de conectar: com `autoReconnect` o `mqtt_client` 10.5.1 não chama `onDisconnected`, só `onAutoReconnect`, por isso `MqttSecureClient` trata os dois como queda (`test/mqtt_drop_after_connect_test.dart`) e usa `disconnectOnNoResponsePeriod = 10` contra a conexão meio aberta. O `AcsHomeShell` consulta sempre ao abrir o painel, ao voltar ao primeiro plano e no instante em que a reserva arma, e a cada `AcsHomeShell.defaultAlertFallbackInterval` (30 s; `alertFallbackInterval` nos testes) **só** enquanto o broker está desconectado e o app em primeiro plano; uma consulta por vez, e `close()` descarta a resposta que chega depois do `dispose`. Com a reserva ligada o painel mostra `Key('alert_fallback_status')` (`liveRegion`): "Recebendo alertas pelo servidor a cada 30 s. Última consulta às HH:MM." ou "…também falhou…"; o chip do cabeçalho continua "Sem central". Sessão vencida na consulta segue o `onSessionExpired` de sempre. Prova: `scripts/qa/acs_alert_fallback_e2e.sh` (broker fora do alcance do aparelho, alerta inserido por SQL sem `alert_outbox`, intervalo de produção; estrutura guardada por `acs_alert_fallback_e2e_test.sh`). Sem consulta em segundo plano e sem alertas gravados no aparelho. From e344e7be2b8b850693681069073ba0a0d6351e18 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Sat, 17 Oct 2026 17:52:24 -0400 Subject: [PATCH 17/17] docs: aponta o certificado por aparelho para a issue #94 (#81) --- PROGRESS.md | 4 ++-- docs/auditoria-prd/2026-10-15-lacunas-prd.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 330895f..6367997 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1699,13 +1699,13 @@ Backlog priorizado na seção final do relatório; conferência por `python3 scr ## Limpeza remota do aparelho e rotação da chave (#81) — 2026-10-17 - Entregue: o coordenador da UBS ou o administrador ordena a limpeza de um aparelho do ACS pelo diálogo "Aparelhos" do cartão do ACS no backoffice (`admin.acsDevices`, `admin.orderDeviceWipe`; motivo Perdido/Roubado/Substituído). O pedido grava `device_wipe_orders` (migração `20261017171640323`) e revoga, na mesma transação, os tokens de todos os usuários daquele `deviceId`. O app ACS (`devices.pendingWipe`/`confirmWipe`, `RemoteWipeCoordinator`, `WipeJournal`) aplica na partida, na retomada, na volta da rede e quando a renovação/login/envio diferido recebe `DeviceWipeRequiredException`: desmonta o painel, descarta as filas em RAM sem gravar, apaga o banco SQLCipher, sidecars e `.recuperado`, troca a chave, apaga os tokens de envio e o `deviceId`, mostra `device_wiped` e confirma ao servidor. Aparelho limpo entra de novo com um `deviceId` novo. "Limpar este aparelho" (local) também passou a apagar o arquivo e trocar a chave (`VisitDatabase.destroy`). -- Decisões: D1 identidade do aparelho = `deviceId` da instalação, sem tabela de aparelhos (lista = `acs_refresh_tokens` ∪ `acs_upload_tokens`; índice único em `device_wipe_orders.deviceId`); D2 o wipe remoto ignora o bloqueio de visitas não enviadas (o diálogo do admin avisa; o local segue recusando); D3 com o aparelho online a entrega ocorre em até 15 min (JWT vivo não é revogado por chamada; a renovação seguinte recebe a exceção; partida, retomada e volta da rede perguntam na hora); D4 sem cancelamento; D5 revogação por aparelho, não por conta (cortar a conta é `setAcsActive(false)`, #43); D6 rotação da chave **por evento, não por calendário** (sem `PRAGMA rekey` periódico, que reescreveria o arquivo com visitas pendentes dentro; registrado em `spec/lgpd_design.md` §5.1); D7 diário gravado antes de apagar (`erasing`/`erased`, só `orderId` e `deviceId` antigo); D8 certificado de cliente por aparelho adiado para a issue de continuação (a abrir), com o certificado de dev `acs-area-12` ainda no APK de debug e o release recusando a chave privada no asset. INV-03: os alertas seguem `pending` no servidor e o escalonamento da #44 continua valendo. +- Decisões: D1 identidade do aparelho = `deviceId` da instalação, sem tabela de aparelhos (lista = `acs_refresh_tokens` ∪ `acs_upload_tokens`; índice único em `device_wipe_orders.deviceId`); D2 o wipe remoto ignora o bloqueio de visitas não enviadas (o diálogo do admin avisa; o local segue recusando); D3 com o aparelho online a entrega ocorre em até 15 min (JWT vivo não é revogado por chamada; a renovação seguinte recebe a exceção; partida, retomada e volta da rede perguntam na hora); D4 sem cancelamento; D5 revogação por aparelho, não por conta (cortar a conta é `setAcsActive(false)`, #43); D6 rotação da chave **por evento, não por calendário** (sem `PRAGMA rekey` periódico, que reescreveria o arquivo com visitas pendentes dentro; registrado em `spec/lgpd_design.md` §5.1); D7 diário gravado antes de apagar (`erasing`/`erased`, só `orderId` e `deviceId` antigo); D8 certificado de cliente por aparelho adiado para a issue #94, com o certificado de dev `acs-area-12` ainda no APK de debug e o release recusando a chave privada no asset. INV-03: os alertas seguem `pending` no servidor e o escalonamento da #44 continua valendo. - Prova: `./scripts/qa/acs_remote_wipe_e2e.sh` no emulador API 36 contra o banco de teste, 2026-10-17: ACS com uma visita não enviada e o painel aberto, pedido `stolen` do coordenador, ciclo de vida inactive→resumed, tela `device_wiped`, arquivo do banco ausente após a limpeza, chave trocada, `deviceId` novo, tabelas reabertas vazias, login novo sem visita pendente; no banco, tokens do id antigo revogados, linhas `wipe_ordered`/`wipe_delivered`/`wipe_confirmed` com o mesmo `orderId`, nenhuma visita do ACS no servidor, `audit_chain_check` verde (18 linhas). Log em `docs/superpowers/plans/2026-10-17-issue-81-e2e.log`. Testes: `device_wipe_service_test.dart`, `device_wipe_store_test.dart`, `device_wipe_endpoint_test.dart` (backend); `device_wipe_test.dart`, `remote_wipe_*`/painel aberto, `device_wiped` (ACS); `acs_devices_dialog_test.dart`, `touch_targets_test.dart` (admin). Screenshots das telas ainda não capturadas (`docs/telas-admin.md`, `docs/telas-acs.md`). - Aberto: - Aparelho offline para sempre não é alcançável (inerente ao modelo, D3). - O JWT vivo vale até 15 min depois do pedido; a limpeza só chega na renovação seguinte ou numa consulta do app. - A partida do ACS com o servidor mudo espera até 5 s pela confirmação pendente do diário (`resumePending(confirmTimeout:)`; a limpeza em si não tem teto e termina antes de qualquer uso do banco) e mais até 5 s pela consulta `pendingWipe`, antes de mostrar o login: até ~10 s, no lugar dos ~25 s de antes (`remote_wipe_ui_test.dart`, "servidor mudo na confirmação da partida"). - - Certificado de cliente MQTT por aparelho (D8, STRIDE-S): issue de continuação (a abrir); rascunho em `.superpowers/sdd/2026-10-17-issue-81-wipe-remoto-e-rotacao-de-chave/followup-issue.md`. + - Certificado de cliente MQTT por aparelho (D8, STRIDE-S): issue #94. - R3 do PRD segue **parcial** (`docs/auditoria-prd`): faltam também a chave derivada de biometria que o PRD pede (a do SQLCipher fica no Keystore sem amarração, §5.1). - Prova só na API 36 do emulador; API 24–29 e aparelho físico sem prova. - `pendingWipe`/`confirmWipe` são públicos: quem tem o `deviceId` pode carimbar entregue/confirmado sem apagar nada (o acesso segue bloqueado, porque o id antigo fica marcado para sempre). "Aparelho limpo" no admin é a afirmação do aparelho, não prova. diff --git a/docs/auditoria-prd/2026-10-15-lacunas-prd.md b/docs/auditoria-prd/2026-10-15-lacunas-prd.md index 20fcc63..90e1254 100644 --- a/docs/auditoria-prd/2026-10-15-lacunas-prd.md +++ b/docs/auditoria-prd/2026-10-15-lacunas-prd.md @@ -79,7 +79,7 @@ próprio; o conferidor exige apenas os IDs do PRD. | ID | Requisito | Status | Evidência | O que falta | |----|-----------|--------|-----------|-------------| -| STRIDE-S | Spoofing: mensagens falsas no MQTT | **parcial** | Broker com senha + ACL por tópico + certificado de cliente obrigatório (`infra/docker/mosquitto/mosquitto.conf:10`); `scripts/qa/mtls_invariants.sh` OK (2026-10-15). | Certificado de cliente por aparelho em produção (hoje um certificado de dev compartilhado, `acs-area-12.crt`); adiado na #81 (D8) para a issue de continuação (a abrir). | +| STRIDE-S | Spoofing: mensagens falsas no MQTT | **parcial** | Broker com senha + ACL por tópico + certificado de cliente obrigatório (`infra/docker/mosquitto/mosquitto.conf:10`); `scripts/qa/mtls_invariants.sh` OK (2026-10-15). | Certificado de cliente por aparelho em produção (hoje um certificado de dev compartilhado, `acs-area-12.crt`); adiado na #81 (D8) para a issue #94. | | STRIDE-T | Tampering: triagem alterada no dispositivo | **parcial** | O risco é recalculado no servidor (INV-02), então adulterar a triagem no aparelho não muda a classificação. | Não há a assinatura digital dos dados de triagem no dispositivo que o PRD descreve; decidir se a reclassificação no servidor substitui a assinatura e registrar. | | STRIDE-R | Repudiation: logs append-only assinados | **parcial** | Cadeia HMAC em `audit_logs` detecta remoção e alteração (`audit_chain_check`). | Append-only no banco (trigger/`REVOKE`) não existe: ver RF17. | | STRIDE-I | Information disclosure: PII no dispositivo | **entregue** | SQLCipher no ACS com chave no Keystore (`apps/acs/lib/core/security/database_key_store.dart`), `FLAG_SECURE` na janela do ACS (`secure_window_test`). | O app do paciente guarda lembretes de saúde em texto livre (medicamento, pesagem: `apps/patient/lib/core/reminders/reminder.dart:1`) em `sqflite` sem cifra, por decisão; reavaliar se o campo passar a aceitar diagnóstico. | @@ -212,7 +212,7 @@ próprio; o conferidor exige apenas os IDs do PRD. |----|-----------|--------|-----------|-------------| | 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 | **entregue** (2026-10-17, #80) | Reconexão com backoff e sessão persistente no MQTT; outbox no servidor reentrega (`mqtt_resiliencia_e2e.sh`); visitas já vão por HTTP; reserva por HTTP dos alertas: `alerts.openForMicroArea` consultado pelo ACS a cada 30 s enquanto o broker está fora (`AlertHttpFallback`, `AlertQueue.mergeFromServer`); no `emulator-5554`, alerta sem outbox chegou à tela em 29,6 s e foi confirmado (`scripts/qa/acs_alert_fallback_e2e.sh`). | Consulta só em primeiro plano (sem WorkManager, fora de `spec/stack.md`); nenhum alerta gravado no aparelho (decisão D5 da #80). | -| R3 | Vazamento de PII no dispositivo | **parcial** | Limpeza do aparelho pelo próprio ACS (`device_wipe_test.dart`); chave do SQLCipher no Keystore; limpeza remota pelo backoffice e rotação da chave por evento (#81, 2026-10-17): `admin.orderDeviceWipe`, `devices.pendingWipe`/`confirmWipe`, `RemoteWipeCoordinator`; prova no emulador API 36 em `./scripts/qa/acs_remote_wipe_e2e.sh` (log `docs/superpowers/plans/2026-10-17-issue-81-e2e.log`). | Certificado de cliente por aparelho (issue de continuação, a abrir); chave derivada de biometria, que o PRD pede (a chave do SQLCipher fica no Keystore sem amarração de autenticação, por decisão do `spec/lgpd_design.md` §5.1); aparelho offline nunca alcançado e JWT vivo até 15 min; API 24–29 e aparelho físico sem prova. | +| R3 | Vazamento de PII no dispositivo | **parcial** | Limpeza do aparelho pelo próprio ACS (`device_wipe_test.dart`); chave do SQLCipher no Keystore; limpeza remota pelo backoffice e rotação da chave por evento (#81, 2026-10-17): `admin.orderDeviceWipe`, `devices.pendingWipe`/`confirmWipe`, `RemoteWipeCoordinator`; prova no emulador API 36 em `./scripts/qa/acs_remote_wipe_e2e.sh` (log `docs/superpowers/plans/2026-10-17-issue-81-e2e.log`). | Certificado de cliente por aparelho (issue #94); chave derivada de biometria, que o PRD pede (a chave do SQLCipher fica no Keystore sem amarração de autenticação, por decisão do `spec/lgpd_design.md` §5.1); aparelho offline nunca alcançado e JWT vivo até 15 min; API 24–29 e aparelho físico sem prova. | | R4 | Rejeição do ACS | **externo** | Nada no código. | NPS semanal e treinamento (produto + UBS). | | R5 | Performance do PostgreSQL | **parcial** | 46 índices na migração `20261014155304871`, incluindo `alerts_micro_area_status_idx` e `users_micro_area_idx`. | Particionamento por microárea não existe; sem teste de carga que mostre necessidade (M3.3). | | R6 | Dependência de SMS gateway | **parcial** | O ACS pode gerar quantas vezes quiser um convite QR de uso único para paciente da microárea dele (`backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart:21`), e `completeEnrollment` emite uma sessão do paciente (`onboarding_endpoint.dart:37,70`): é metade do fallback por QR. Nenhum provedor de SMS (RF01). | Entrada sem o ACS presente quando o SMS falha; biometria; múltiplos provedores de SMS. | @@ -231,7 +231,7 @@ Critério: **P0** toca invariante ou o núcleo do MVP (§6.1) sem critério atin | 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; 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~~ — resolvido na #80 (2026-10-17): `alerts.openForMicroArea` consultado a cada 30 s com o broker fora | R2, INV-03 | código | Risco de impacto 5 em rede 3G; antes, o ACS não tinha como listar alertas pendentes por HTTP. | -| 7 | P1 | ~~Remote wipe~~ e certificado de cliente por aparelho — o remote wipe e a rotação da chave foram entregues na #81 (2026-10-17), mas R3 segue **parcial**; o certificado por aparelho segue na issue de continuação (a abrir) | R3, STRIDE-S, CRIPTO-APP-CACHE | código + infra | Risco de impacto 5; o certificado depende de uma lib X.509/CSR fora de `spec/stack.md`, de uma CA de aparelhos e de PKI de produção. | +| 7 | P1 | ~~Remote wipe~~ e certificado de cliente por aparelho — o remote wipe e a rotação da chave foram entregues na #81 (2026-10-17), mas R3 segue **parcial**; o certificado por aparelho segue na issue #94 | R3, STRIDE-S, CRIPTO-APP-CACHE | código + infra | Risco de impacto 5; o certificado depende de uma lib X.509/CSR fora de `spec/stack.md`, de uma CA de aparelhos 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. | | 9 | P1 | Telemetria estruturada dos eventos do §1.3 | EVT-*, RNF02, H1, H2, H4, MOD-RELATORIOS | código | Sem os eventos, a North Star e três hipóteses não são mensuráveis no piloto. | | 10 | P2 | Rate limiting no Mosquitto e no Traefik | STRIDE-D | infra | DoS listado no STRIDE sem mitigação. |