feat(C108): sino de notificações — abrir marca tudo como lido, painel limpo, modal central no desktop

openCampaignNotifications (lista + marca em 1 round trip) substitui
listCampaignNotifications + markAllCampaignNotificationsRead; markAllNotificationsRead
extraída (owner-scoped, idempotente, cap 200) com int test; bell vira Dialog
desktop / Drawer mobile (par C94), sem título/rodapé/botões, skeleton de
loading, safe-area, focus-visible, role=alert; readAt sai do contrato do item;
P4-D ledger atualizado (batch sem transação agora no hot path, self-healing).
This commit is contained in:
Francisco Solla 2026-08-10 02:09:41 -03:00
parent 07464b8810
commit c3f10cce23
10 changed files with 492 additions and 158 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
--- ---
**Recently resolved (2026-08-10):** **C108** (Issue #522) — sino de notificações: **abrir já lê** — `openCampaignNotifications` (action única) lista + marca tudo como lido em 1 round trip, substituindo `listCampaignNotifications` + `markAllCampaignNotificationsRead`; o badge zera na abertura sem clique e o botão "Marcar todas como lidas" deixa de existir. Painel limpo em qualquer tamanho: sem título "Notificações" visível (sr-only para a11y), sem contador, sem botão "Fechar"; desktop vira **modal central** (Dialog shadcn, X no canto superior direito, clique fora fecha — par responsivo Dialog/Drawer do C94 via `useIsMobile`); mobile segue bottom sheet com handle, safe-area e **skeleton de loading** (o painel não abre mais em branco durante o round trip). `markAllNotificationsRead` extraída para `src/utilities/notification/notificationList.ts` (owner-scoped com bypass documentado, idempotente, cap 200 mantido; int testado: marca só as próprias, deixa as do outro intactas); `readAt` saiu do contrato `NotificationListItem` (sem leitores após a remoção do destaque de não lida); copy de erro unificada em `CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE`; `router.refresh()` re-sincroniza a instância irmã do outro header só quando algo foi marcado (race cross-tab documentado); foco visível nos itens e `role="alert"` no erro. P4-D atualizado no ledger — o batch sem transação agora dispara a cada abertura (self-healing). Testes: int (owner-scope + idempotência) e unit novos do bell (dialog limpo, refresh condicional, empty/error, sheet mobile). Sem migration, access, Consent ou schema. Intenção [`sino-notificacoes-leitura-auto-modal-desktop.md`](plans/sino-notificacoes-leitura-auto-modal-desktop.md); impl [`sino-notificacoes-leitura-auto-modal-desktop-impl.md`](plans/sino-notificacoes-leitura-auto-modal-desktop-impl.md).
**Recently resolved (2026-08-10):** **B185** (Issue #524) — o Sollinha ganhou a tool **`getPendingLeaderships`** (`src/utilities/ai/tools/getPendingLeaderships.ts`): "quais lideranças ainda precisam ser abordadas" por **território de identidade** (grafia tolerante a acento — "Vale do Jiquiriça" resolve para a região canônica), **cidade** ("Salvador" = as 19 ZE juntas) ou **município** (aliases canônicos), com o **critério de pendência sempre declarado na resposta** (`'A abordar'/'Em disputa', ou 'Engajado' sem compromisso de votos no escopo consultado` — eixo do compromisso = presença de `votePledge` liderança×município no escopo; `negativo` é excluído na query, fail-closed), lista com nome/municípios/status/última atualização/**assessores responsáveis** (nomes resolvidos em query própria — nunca `depth` populando `campaignUser`), top N (default 20, max 50) com `total` e `truncado`, escopo omitido = escopo de acesso do usuário. Filtro **C3** `sem_assessor` (`advisors: { exists: false }`, precedente B176) e modo **C4** `municipios_sem_lideranca` (municípios do escopo sem nenhuma liderança). **Gate staff inline** no topo do `execute` (leader → `{ error: 'Leitura de dados de lideranças negada.' }` antes de qualquer query, mesmo shape da B180; 1 call site — sem helper); leituras todas `overrideAccess: false, user: ctx.user` (RBAC atual). Links via `buildCampaignLinks` existente (nada mudou lá); seção nova no system prompt manda declarar o critério. Unit tests novos (`pendingLeadershipsTool.unit.spec.ts`, 14) — gate fail-closed com payload intocado, critério por status+pledge, pledge fora do escopo não satisfaz (where `municipality in`), escopos com acento/Salvador/município/erro, `sem_assessor` no where, C4, truncamento, nomes de assessores; allowlist na convenção P3-D justificada (filtro de domínio sobre set resolvido ≠ escopo de actor). Sem migration, access change ou Consent. Intenção [`sollinha-liderancas-pendentes-abordagem.md`](plans/sollinha-liderancas-pendentes-abordagem.md); impl [`sollinha-liderancas-pendentes-abordagem-impl.md`](plans/sollinha-liderancas-pendentes-abordagem-impl.md). **Recently resolved (2026-08-10):** **B185** (Issue #524) — o Sollinha ganhou a tool **`getPendingLeaderships`** (`src/utilities/ai/tools/getPendingLeaderships.ts`): "quais lideranças ainda precisam ser abordadas" por **território de identidade** (grafia tolerante a acento — "Vale do Jiquiriça" resolve para a região canônica), **cidade** ("Salvador" = as 19 ZE juntas) ou **município** (aliases canônicos), com o **critério de pendência sempre declarado na resposta** (`'A abordar'/'Em disputa', ou 'Engajado' sem compromisso de votos no escopo consultado` — eixo do compromisso = presença de `votePledge` liderança×município no escopo; `negativo` é excluído na query, fail-closed), lista com nome/municípios/status/última atualização/**assessores responsáveis** (nomes resolvidos em query própria — nunca `depth` populando `campaignUser`), top N (default 20, max 50) com `total` e `truncado`, escopo omitido = escopo de acesso do usuário. Filtro **C3** `sem_assessor` (`advisors: { exists: false }`, precedente B176) e modo **C4** `municipios_sem_lideranca` (municípios do escopo sem nenhuma liderança). **Gate staff inline** no topo do `execute` (leader → `{ error: 'Leitura de dados de lideranças negada.' }` antes de qualquer query, mesmo shape da B180; 1 call site — sem helper); leituras todas `overrideAccess: false, user: ctx.user` (RBAC atual). Links via `buildCampaignLinks` existente (nada mudou lá); seção nova no system prompt manda declarar o critério. Unit tests novos (`pendingLeadershipsTool.unit.spec.ts`, 14) — gate fail-closed com payload intocado, critério por status+pledge, pledge fora do escopo não satisfaz (where `municipality in`), escopos com acento/Salvador/município/erro, `sem_assessor` no where, C4, truncamento, nomes de assessores; allowlist na convenção P3-D justificada (filtro de domínio sobre set resolvido ≠ escopo de actor). Sem migration, access change ou Consent. Intenção [`sollinha-liderancas-pendentes-abordagem.md`](plans/sollinha-liderancas-pendentes-abordagem.md); impl [`sollinha-liderancas-pendentes-abordagem-impl.md`](plans/sollinha-liderancas-pendentes-abordagem-impl.md).
**Recently resolved (2026-08-09):** **C104** (Issue #505) — compromissos **"Todo o dia"** na agenda: o sheet de criação rápida (C91) e o formulário completo ganharam o toggle **Todo o dia** (checkbox; no sheet entre o título e Início/Término, no form no topo do card "Data e horário") — ligado, Início e Término viram **só datas** (término pode ser outro dia = faixa de vários dias inteiros; dia único = `startAt === endAt`). Representação: campo novo `allDay` (migration `20260810_010844_add_activity_all_day`) + convenção de instantes **fim inclusivo** (startAt = 1º dia 00:00 America/Bahia, endAt = último dia 00:00; Bahia sem DST); conversões exclusivo↔inclusivo (FullCalendar/iCal usam fim exclusivo) concentradas no módulo puro `lib/activityAllDay.ts` (unit-pinado: dia único, multi-dia, virada de mês/ano, rótulo "10/08/2026 a 12/08/2026"). Calendário: `allDaySlot` ligado (faixa no topo de semana/dia), `toEventInput` com `allDay`, reschedule converte os `startStr`/`endStr` exclusivos e envia `allDay`; **guard de drop**: arrastar entre a lane all-day e a grade de horário é rejeitado (revert + toast — converter por drag é feature fora do escopo). "Mais detalhes" preserva a escolha (`allDay=1` + datas civis nos params; form nasce com toggle ligado). Rótulos all-day em card, overview, dossiê do município, cabeçalho de detalhe e home search; feed iCal exporta `DTSTART;VALUE=DATE`/`DTEND;VALUE=DATE` com fim exclusivo. Liderança não consegue flipar `allDay` (snapshot staff). Sem access/Consent. Intenção [`agenda-toggle-todo-o-dia.md`](plans/agenda-toggle-todo-o-dia.md); impl [`agenda-toggle-todo-o-dia-impl.md`](plans/agenda-toggle-todo-o-dia-impl.md). **Recently resolved (2026-08-09):** **C104** (Issue #505) — compromissos **"Todo o dia"** na agenda: o sheet de criação rápida (C91) e o formulário completo ganharam o toggle **Todo o dia** (checkbox; no sheet entre o título e Início/Término, no form no topo do card "Data e horário") — ligado, Início e Término viram **só datas** (término pode ser outro dia = faixa de vários dias inteiros; dia único = `startAt === endAt`). Representação: campo novo `allDay` (migration `20260810_010844_add_activity_all_day`) + convenção de instantes **fim inclusivo** (startAt = 1º dia 00:00 America/Bahia, endAt = último dia 00:00; Bahia sem DST); conversões exclusivo↔inclusivo (FullCalendar/iCal usam fim exclusivo) concentradas no módulo puro `lib/activityAllDay.ts` (unit-pinado: dia único, multi-dia, virada de mês/ano, rótulo "10/08/2026 a 12/08/2026"). Calendário: `allDaySlot` ligado (faixa no topo de semana/dia), `toEventInput` com `allDay`, reschedule converte os `startStr`/`endStr` exclusivos e envia `allDay`; **guard de drop**: arrastar entre a lane all-day e a grade de horário é rejeitado (revert + toast — converter por drag é feature fora do escopo). "Mais detalhes" preserva a escolha (`allDay=1` + datas civis nos params; form nasce com toggle ligado). Rótulos all-day em card, overview, dossiê do município, cabeçalho de detalhe e home search; feed iCal exporta `DTSTART;VALUE=DATE`/`DTEND;VALUE=DATE` com fim exclusivo. Liderança não consegue flipar `allDay` (snapshot staff). Sem access/Consent. Intenção [`agenda-toggle-todo-o-dia.md`](plans/agenda-toggle-todo-o-dia.md); impl [`agenda-toggle-todo-o-dia-impl.md`](plans/agenda-toggle-todo-o-dia-impl.md).

View file

@ -50,7 +50,7 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
| homeSearch text-search pipeline re-spelled per domain (gate staff → normalize → `contains` + `limit: 0` → word-start → relevance → cap), plus **unbounded `limit: 0` reads** ×5 before the in-memory refine | `src/utilities/homeSearch/searchHomeActivities.ts` (88) ↔ `searchHomeDemands.ts` (100), sim 0.72 (~63 shared lines); leaderships 0.53; `limit: 0` at `searchHomeActivities.ts:43`, `Demands:49`, `Leaderships:61`, `Advisors:37`, `StateDeputies:45` | duplication + scale | medium — every keystroke can pull a full collection slice | M | P2 | open — Pass 4 | P4-A | | homeSearch text-search pipeline re-spelled per domain (gate staff → normalize → `contains` + `limit: 0` → word-start → relevance → cap), plus **unbounded `limit: 0` reads** ×5 before the in-memory refine | `src/utilities/homeSearch/searchHomeActivities.ts` (88) ↔ `searchHomeDemands.ts` (100), sim 0.72 (~63 shared lines); leaderships 0.53; `limit: 0` at `searchHomeActivities.ts:43`, `Demands:49`, `Leaderships:61`, `Advisors:37`, `StateDeputies:45` | duplication + scale | medium — every keystroke can pull a full collection slice | M | P2 | open — Pass 4 | P4-A |
| HomeSearch result groups ×6 near-verbatim (Advisor↔Leadership, Activity↔Demand almost line-for-line; differ only in id/title/href/secondary) | `src/components/campaign/dashboard/HomeSearch*Group.tsx` (40–63 lines each; Municipality is the real variant) | duplication | low | M | P2 | open — Pass 4 | P4-B | | HomeSearch result groups ×6 near-verbatim (Advisor↔Leadership, Activity↔Demand almost line-for-line; differ only in id/title/href/secondary) | `src/components/campaign/dashboard/HomeSearch*Group.tsx` (40–63 lines each; Municipality is the real variant) | duplication | low | M | P2 | open — Pass 4 | P4-B |
| `notificationEvents` re-spells names-by-id ×7 instead of the P3-E wrappers | `src/utilities/notification/notificationEvents.ts` (`:41-57`, `:143-174`, `:212-236` — `findByID` + `select:{name}`/`{name,slug}`) | duplication | low | S | P2 | open — Pass 4 | P4-C | | `notificationEvents` re-spells names-by-id ×7 instead of the P3-E wrappers | `src/utilities/notification/notificationEvents.ts` (`:41-57`, `:143-174`, `:212-236` — `findByID` + `select:{name}`/`{name,slug}`) | duplication | low | S | P2 | open — Pass 4 | P4-C |
| Notification write path: `markAllCampaignNotificationsRead` updates up to 200 rows with no transaction; `subscribeCampaignPush` validates with `.trim()` only and always creates (re-subscribe → unique violation → generic error); `unsubscribeCampaignPush` deletes with `overrideAccess: true` while `canDeleteOwnPushSubscriptions` already scopes the owner | `src/app/(campaign)/campanha/actions/notifications.ts:69-91,100-143,172-176` | correctness | medium — partial mark-read on mid-batch failure; hostile re-subscribe UX; bypass surface | S | P2 | open — Pass 4 | P4-D | | Notification write path: `markAllNotificationsRead` updates up to 200 rows with no transaction (relocated from the removed panel button action by C108 — it now fires on every bell open, so a mid-batch failure leaves a partial mark that the next open completes, idempotently); `subscribeCampaignPush` validates with `.trim()` only and always creates (re-subscribe → unique violation → generic error); `unsubscribeCampaignPush` deletes with `overrideAccess: true` while `canDeleteOwnPushSubscriptions` already scopes the owner | `src/utilities/notification/notificationList.ts:104-117`, `src/app/(campaign)/campanha/actions/notifications.ts:88-131,137-145` | correctness | medium — partial mark-read on mid-batch failure (self-healing); hostile re-subscribe UX; bypass surface | S | P2 | open — Pass 4 | P4-D |
| Wizard info Drawer chrome spelled ×3 (button → bottom sheet with title+description+body); skip trailing spelled ×2 (13 lines each, both `{label, href}`) | `WizardSignalTypeStep.tsx:95-112`, `WizardTrendChoiceStep.tsx:115-132`, `WizardLeadershipStep.tsx:167-179`; `WizardSignalSkipTrailing.tsx` ↔ `WizardTrendSkipTrailing.tsx` | duplication | low | S | P2 | open — Pass 4 | P4-E | | Wizard info Drawer chrome spelled ×3 (button → bottom sheet with title+description+body); skip trailing spelled ×2 (13 lines each, both `{label, href}`) | `WizardSignalTypeStep.tsx:95-112`, `WizardTrendChoiceStep.tsx:115-132`, `WizardLeadershipStep.tsx:167-179`; `WizardSignalSkipTrailing.tsx` ↔ `WizardTrendSkipTrailing.tsx` | duplication | low | S | P2 | open — Pass 4 | P4-E |
| `MunicipalityListMobileCards` (client) value-imports `municipalityColumnLabels` from `municipalityListUrl` — a ~19 KB URL module (pulls `isMunicipalitySlug` → `municipalityCatalog`) for a string map whose type already lives in `municipalityLabels` | `src/components/campaign/municipality/MunicipalityListMobileCards.tsx:26` | boundary (B14 class) | low | S | P2 | open — Pass 4 | P4-F | | `MunicipalityListMobileCards` (client) value-imports `municipalityColumnLabels` from `municipalityListUrl` — a ~19 KB URL module (pulls `isMunicipalitySlug` → `municipalityCatalog`) for a string map whose type already lives in `municipalityLabels` | `src/components/campaign/municipality/MunicipalityListMobileCards.tsx:26` | boundary (B14 class) | low | S | P2 | open — Pass 4 | P4-F |
| `HomeSearchProvider` wraps the whole chrome incl. `actions` + `summarySlot` non-consumers, and the provider value changes on every keystroke | `src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx:41-56`, `useHomeSearchQuery.ts:63-74` | state placement | low — actions/summary re-render per keypress | S | P2 | open — Pass 4 | P4-G | | `HomeSearchProvider` wraps the whole chrome incl. `actions` + `summarySlot` non-consumers, and the provider value changes on every keystroke | `src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx:41-56`, `useHomeSearchQuery.ts:63-74` | state placement | low — actions/summary re-render per keypress | S | P2 | open — Pass 4 | P4-G |

View file

@ -0,0 +1,72 @@
# Impl: Sino de notificações: ler ao abrir + painel limpo + modal centrado no desktop
Status: aprovado
Atualizado em: 2026-08-10
Issue: #522
Intenção: docs/plans/sino-notificacoes-leitura-auto-modal-desktop.md
Appetite restante: herdado (~0,5–1 dia eng; a mudança é contida — uma action + um componente + testes)
## Leitura da intenção
- **Outcome:** abrir o sino marca todas as notificações do usuário como lidas (badge zera sem clique, "Marcar todas como lidas" deixa de existir); o painel não exibe título "Notificações" nem botão "Fechar" em nenhum tamanho; desktop = modal central com X no canto superior direito + clique fora fecha (par Dialog/Drawer do C94); mobile = bottom sheet; destaque visual de "não lida" sai (nada fica não lido depois da abertura).
- **O que NÃO negociar:** guardrail leader lockdown (notificações continuam por destinatário `recipient`); nenhuma mudança de acesso, consentimento ou schema; gatilho do sino é slot existente — não trocar contrato; anti-goals: sem leitura individual, sem central de notificações.
- **O que reavaliar:** a intenção assume disparar `markAllCampaignNotificationsRead` na abertura (ação existente + listagem separada = 2 round trips). É caro de reverter? Não — é barato escolher na implementação; decidido aqui (abaixo).
## Abordagem recomendada
```mermaid
flowchart LR
A[Abrir sino] --> B[openCampaignNotifications\nserver action única]
B --> C[loadNotificationList]
B --> D[markAllNotificationsRead]
C --> E[items no painel]
D --> F[unreadCount = 0 local + router.refresh]
F --> G[badge zera; instância oculta do header sincroniza]
E --> H[Dialog desktop / Drawer mobile]
```
**Opções consideradas:** A | B
**Recomendação:** B — **ação única `openCampaignNotifications`** (lista + marca como lida numa só action, um round trip), porque: (1) a semântica de produto é literalmente "abrir = ler" — a mutation mora na própria ação de abertura, sem efeito colateral em loader; (2) elimina a janela em que a lista renderiza itens "não lidos" enquanto a marcação viaja; (3) o fluxo do painel continua 1 round trip por abertura (hoje é 1 só de listagem; com A seriam 2).
**Rejeitadas:** A — chamar `markAllCampaignNotificationsRead` em paralelo ao `listCampaignNotifications` no `useEffect` de abertura: difere menos, mas são 2 round trips, possível flash de dot "não lida" quando a listagem vence a marcação, e mantém uma ação que só existiria para um botão removido. E marcar **no fechar** (rabbit hole de produto, já cortado pela intenção).
### Componentes / mudanças
- **`markAllNotificationsRead(payload, user)`** (`src/utilities/notification/notificationList.ts` — módulo dono dos dados do painel do sino, ao lado de `countUnreadNotifications`/`loadNotificationList`): find unread por `recipient = user.id` (limit 200, como hoje) + `Promise.all` de updates `{ readAt }`, `overrideAccess: true` (mesmo padrão da action atual: destinatário verificado na action pelo session user, bypass admin com justificativa própria — a coleção `notification.update` é `canWriteNotifications`, staff/admin). Retorna o número marcado. Extraída para ser testável (int spec) — a action hoje in-line não é testável sem sessão.
- **`openCampaignNotifications`** (`src/app/(campaign)/campanha/actions/notifications.ts`, substitui `listCampaignNotifications` + `markAllCampaignNotificationsRead`): `runCampaignFormAction` → getCampaignUser (auth required) → `loadNotificationList` + `markAllNotificationsRead` → `revalidatePath('/campanha', 'layout')` quando `markedCount > 0` → `{ items, markedCount, message }` (message satisfaz o contrato do ladder — reworded para "Notificações atualizadas.", verdadeiro em ambos os casos). Erro → `{ message }` (genericMessage: `CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE` nova em `campaignNotificationCopy.ts`, compartilhada com o fallback do cliente; copy `CAMPAIGN_NOTIFICATION_MARK_ALL_READ_ERROR_MESSAGE` sai — knip pegaria export órfão).
- **`CampaignNotificationBell`** (`src/components/campaign/shell/CampaignNotificationBell.tsx`): `useIsMobile()` → desktop `Dialog` (shadcn, X + clique fora + Esc nativos) / mobile `Drawer` (handle de swipe, sem header/footer) — precedente `CalendarFeedDialog` (C94). Remove `DrawerHeader`/título/contador, `DrawerFooter`, `handleMarkAllRead`, dot de não lida e highlight `isUnread` no item. Título/descrição `sr-only` no Dialog **e** no Drawer (Radix e base-ui exigem nome acessível; a intenção proíbe título visual — sr-only satisfaz ambos). Loading: 2 skeletons `animate-pulse` enquanto `isPending && items.length === 0`; erro com `role="alert"`; itens com focus-visible ring; sheet mobile com `pb-[max(1rem,env(safe-area-inset-bottom))]`; `max-h-[85dvh]` nos dois invólucros. `router.refresh()` só quando `markedCount > 0` (re-sincroniza a instância irmã do outro header; race cross-tab documentado em comentário).
- **Migration:** nenhuma. **Access/Consent:** nenhum (mesma superfície de acesso; leader lockdown intacto).
- **UI:** Impeccable C — shape do canvas `plan-c108-ui-draft.canvas.tsx` → craft → critique → polish (sem trigger harden/optimize).
### Dados → forma (se aplicável)
- Contagem: já existe (`badge` + `readAt`) — nada novo. A linha "X não lidas / Tudo em dia" do header sai junto com o título (recomendação A da intenção, assumida). Após marcação automática ela seria sempre "Tudo em dia", sem função.
## Fases verificáveis
1. **Server** (quota ~40%): `markAllNotificationsRead` + int test (marca só as próprias não lidas; deixa as do outro intactas; retorna contagem) → `openCampaignNotifications` substitui as duas actions + remove copy órfã. `pnpm gate:fast` parcial (tsc + testes de domínio).
2. **UI** (quota ~40%): refactor do bell (Dialog/Drawer, sem título/rodapé, auto-read na abertura) + unit test `campaignNotificationBell.unit.spec.tsx` (mock da action: abre → chama a action uma vez, badge zera, sem título/botões, sem dot).
3. **Gates** (quota ~20%): `pnpm gate:fast`; `pnpm push` com o impl plan incluído.
## Rabbit holes / Não escopo (engenharia)
- **Limite 200 da marcação:** manter (inbox com >200 não lidas é patológico; badge pode não zerar 100% nesse caso). Não paginar agora — gatilho de revisita se o badge for visto zerando parcialmente em produção. Documentado no comentário da utility.
- **Batch sem transação (P4-D):** herdado, agora dispara a cada abertura; falha no meio do batch deixa marcação parcial que a próxima abertura completa (idempotente). Deferido com ledger atualizado (P4-D) — envolver em `withPayloadTransaction` é o plano do P4.
- **`readAt` removido do contrato `NotificationListItem`:** o painel nunca mais o lê (nada é não lido após a abertura); o campo continua no schema (badge/`countUnreadNotifications`).
- **Leitura individual / "deixar X como não lida"** — anti-goal da intenção.
- **Reescrever acesso da coleção** para permitir owner-update (`overrideAccess: false`) — mudança de superfície de acesso fora do escopo; o padrão atual (verificação na action + bypass documentado) já é o do repo (Pass 4).
- **Renomear `notificationList.ts`** para algo mais amplo — churn sem ganho; um export novo no módulo dono basta.
- **Sheet.tsx** para o mobile — existe no repo, mas o precedente canônico da intenção é o par Dialog/Drawer do C94; Drawer já está no componente.
## Riscos e mitigação
- **Duas instâncias do bell no layout** (mobile top bar + desktop header montados juntos, uma oculta): a instância aberta zera o badge local; `router.refresh()` re-sincroniza a outra via `initialUnreadCount` (useEffect existente). Mantido o `router.refresh()`.
- **Radix a11y warnings sem `Title`:** sr-only title+description no Dialog; Drawer sem título visível não quebra base-ui, mas recebe sr-only por paridade.
- **Falha de DB na abertura:** action falha → `loadError` exibido, nada marcado (fail-closed, nada de "ler" indevido).
- **`useIsMobile` mede depois do primeiro frame** (desktop no pré-measurement): mesmo comportamento do C94; o gatilho do sino é o mesmo para os dois invólucros.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (badge zera na abertura; sem título/botões; modal central desktop; sheet mobile)
- [ ] Invariantes AGENTS/engineering-standards (sem schema/access/Consent; copy pt-BR; identificadores en)
- [ ] Testes de domínio previstos: int `markAllNotificationsRead` (owner-scoped write) + unit do bell (painel limpo + auto-read)
- [ ] Zero dead code (knip): `markAllCampaignNotificationsRead`, `listCampaignNotifications`, copy órfã removidos

View file

@ -8,14 +8,13 @@ import { headers } from 'next/headers'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { import {
CAMPAIGN_NOTIFICATION_MARK_ALL_READ_ERROR_MESSAGE, CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE,
CAMPAIGN_NOTIFICATION_THROW_SAFE_MESSAGES, CAMPAIGN_NOTIFICATION_THROW_SAFE_MESSAGES,
CAMPAIGN_PUSH_CONSENT_REQUIRED_MESSAGE, CAMPAIGN_PUSH_CONSENT_REQUIRED_MESSAGE,
CAMPAIGN_PUSH_SUBSCRIBE_ERROR_MESSAGE, CAMPAIGN_PUSH_SUBSCRIBE_ERROR_MESSAGE,
CAMPAIGN_PUSH_SUBSCRIPTION_INVALID_MESSAGE, CAMPAIGN_PUSH_SUBSCRIPTION_INVALID_MESSAGE,
CAMPAIGN_PUSH_UNSUBSCRIBE_ERROR_MESSAGE, CAMPAIGN_PUSH_UNSUBSCRIBE_ERROR_MESSAGE,
} from '@/lib/campaignNotificationCopy' } from '@/lib/campaignNotificationCopy'
import type { NotificationListItem } from '@/lib/notificationContract'
import { getCampaignUser } from '@/utilities/campaignAuth' import { getCampaignUser } from '@/utilities/campaignAuth'
import { requireCampaignPushConsent } from '@/utilities/campaignConsent' import { requireCampaignPushConsent } from '@/utilities/campaignConsent'
import { import {
@ -25,6 +24,7 @@ import {
import { import {
countUnreadNotifications, countUnreadNotifications,
loadNotificationList, loadNotificationList,
markAllNotificationsRead,
} from '@/utilities/notification/notificationList' } from '@/utilities/notification/notificationList'
import { getCampaignVapidPublicKey } from '@/utilities/notification/sendCampaignPush' import { getCampaignVapidPublicKey } from '@/utilities/notification/sendCampaignPush'
@ -51,50 +51,33 @@ export const loadNotificationBellData = async (): Promise<NotificationBellData |
} }
} }
export const listCampaignNotifications = async (): Promise<NotificationListItem[]> => { /**
const user = await getCampaignUser() * The bell's open action (C108): loads the panel list AND marks everything
if (!user) return [] * unread as read in one round trip — opening the panel is the read gesture,
* so the badge zeroes without a separate click. List and mark run
const payload = await getPayload({ config }) * concurrently; the panel no longer reads per-item `readAt`, so the race is
return loadNotificationList(payload, user) * invisible to the UI (the badge is driven by `markedCount`, not the list).
} */
export const openCampaignNotifications = async () =>
export const markAllCampaignNotificationsRead = async () =>
runCampaignFormAction({ runCampaignFormAction({
execute: async () => { execute: async () => {
const user = await getCampaignUser() const user = await getCampaignUser()
if (!user) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE) if (!user) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE)
const payload = await getPayload({ config }) const payload = await getPayload({ config })
const unread = await payload.find({ const [items, markedCount] = await Promise.all([
collection: 'notification', loadNotificationList(payload, user),
where: { markAllNotificationsRead(payload, user),
and: [{ recipient: { equals: user.id } }, { readAt: { exists: false } }], ])
},
depth: 0,
limit: 200,
pagination: false,
overrideAccess: true,
})
const readAt = new Date().toISOString()
await Promise.all(
unread.docs.map((doc) =>
payload.update({
collection: 'notification',
id: doc.id,
data: { readAt },
depth: 0,
overrideAccess: true,
}),
),
)
if (markedCount > 0) {
revalidatePath('/campanha', 'layout') revalidatePath('/campanha', 'layout')
return { message: 'Todas as notificações foram marcadas como lidas.' } }
return { items, markedCount, message: 'Notificações atualizadas.' }
}, },
safeMessages: CAMPAIGN_NOTIFICATION_SAFE_MESSAGES, safeMessages: [CAMPAIGN_AUTH_REQUIRED_MESSAGE],
genericMessage: CAMPAIGN_NOTIFICATION_MARK_ALL_READ_ERROR_MESSAGE, genericMessage: CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE,
}) })
type PushSubscribeInput = { type PushSubscribeInput = {

View file

@ -13,23 +13,16 @@ import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState, useTransition } from 'react' import { useCallback, useEffect, useState, useTransition } from 'react'
import { import {
listCampaignNotifications, openCampaignNotifications,
markAllCampaignNotificationsRead,
type NotificationBellData, type NotificationBellData,
} from '@/app/(campaign)/campanha/actions/notifications' } from '@/app/(campaign)/campanha/actions/notifications'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
Drawer, import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/ui/Drawer'
DrawerCloseButton, import { useIsMobile } from '@/hooks/use-mobile'
DrawerContent, import { CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE } from '@/lib/campaignNotificationCopy'
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import type { NotificationListItem } from '@/lib/notificationContract' import type { NotificationListItem } from '@/lib/notificationContract'
import { notificationTypeLabels, type NotificationType } from '@/lib/notificationContract' import { notificationTypeLabels, type NotificationType } from '@/lib/notificationContract'
import { cn } from '@/lib/utils'
import { formatRelativeAge } from '@/utilities/formatRelativeAge' import { formatRelativeAge } from '@/utilities/formatRelativeAge'
const notificationIcons: Record<NotificationType, LucideIcon> = { const notificationIcons: Record<NotificationType, LucideIcon> = {
@ -41,11 +34,18 @@ const notificationIcons: Record<NotificationType, LucideIcon> = {
type CampaignNotificationBellProps = NotificationBellData type CampaignNotificationBellProps = NotificationBellData
/**
* The notification bell (C108): opening the panel loads the list AND marks
* everything as read in one server call — the open gesture is the read
* gesture, so the badge zeroes without a separate button. Desktop renders a
* centered Dialog (X + click-outside close), mobile a bottom sheet.
*/
export const CampaignNotificationBell = ({ export const CampaignNotificationBell = ({
unreadCount: initialUnreadCount, unreadCount: initialUnreadCount,
vapidPublicKey: _vapidPublicKey, vapidPublicKey: _vapidPublicKey,
}: CampaignNotificationBellProps) => { }: CampaignNotificationBellProps) => {
const router = useRouter() const router = useRouter()
const isMobile = useIsMobile()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [unreadCount, setUnreadCount] = useState(initialUnreadCount) const [unreadCount, setUnreadCount] = useState(initialUnreadCount)
const [items, setItems] = useState<NotificationListItem[]>([]) const [items, setItems] = useState<NotificationListItem[]>([])
@ -57,39 +57,30 @@ export const CampaignNotificationBell = ({
setUnreadCount(initialUnreadCount) setUnreadCount(initialUnreadCount)
}, [initialUnreadCount]) }, [initialUnreadCount])
const loadItems = useCallback(() => { const loadPanel = useCallback(() => {
startTransition(async () => { startTransition(async () => {
setLoadError(null) setLoadError(null)
try { const result = await openCampaignNotifications()
const nextItems = await listCampaignNotifications() if ('status' in result && result.status === 'success') {
setItems(nextItems) setItems(result.items)
setUnreadCount(nextItems.filter((item) => !item.readAt).length) setUnreadCount(0)
} catch { if (result.markedCount > 0) {
setLoadError('Não foi possível carregar as notificações.') // Re-sync the sibling bell instance rendered by the other header.
// (A cross-tab mark racing between this render and the open action
// yields markedCount 0 and a stale sibling badge until the next
// navigation — narrow, self-healing, not worth a refresh per open.)
router.refresh()
}
} else {
setLoadError(result.message ?? CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE)
} }
}) })
}, []) }, [router])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
loadItems() loadPanel()
}, [open, loadItems]) }, [open, loadPanel])
const handleMarkAllRead = () => {
startTransition(async () => {
const result = await markAllCampaignNotificationsRead()
if ('status' in result && result.status === 'success') {
setItems((current) =>
current.map((item) => ({
...item,
readAt: item.readAt ?? new Date().toISOString(),
})),
)
setUnreadCount(0)
router.refresh()
}
})
}
const unreadLabel = const unreadLabel =
unreadCount === 0 unreadCount === 0
@ -98,6 +89,71 @@ export const CampaignNotificationBell = ({
? '1 notificação não lida' ? '1 notificação não lida'
: `${unreadCount} notificações não lidas` : `${unreadCount} notificações não lidas`
const panelItems = (
<>
{loadError ? (
<p role="alert" className="text-sm text-destructive">
{loadError}
</p>
) : null}
{isPending && items.length === 0 ? (
<>
<div className="flex gap-3 rounded-lg border p-3" aria-hidden>
<span className="mt-0.5 size-9 shrink-0 animate-pulse rounded-full bg-muted" />
<span className="min-w-0 flex-1 space-y-2">
<span className="block h-4 w-3/4 animate-pulse rounded bg-muted" />
<span className="block h-3 w-1/2 animate-pulse rounded bg-muted" />
</span>
</div>
<div className="flex gap-3 rounded-lg border p-3" aria-hidden>
<span className="mt-0.5 size-9 shrink-0 animate-pulse rounded-full bg-muted" />
<span className="min-w-0 flex-1 space-y-2">
<span className="block h-4 w-3/4 animate-pulse rounded bg-muted" />
<span className="block h-3 w-1/2 animate-pulse rounded bg-muted" />
</span>
</div>
</>
) : null}
{!loadError && items.length === 0 && !isPending ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Nenhuma notificação por aqui.
</p>
) : null}
{items.map((item) => {
const Icon = notificationIcons[item.type]
return (
<Link
key={item.id}
href={item.payload.href}
onClick={() => setOpen(false)}
className="flex gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted">
<Icon className="size-4 text-muted-foreground" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium leading-snug">{item.payload.title}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{item.payload.detail}
</span>
<span className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{notificationTypeLabels[item.type]}</span>
<span aria-hidden>·</span>
<time dateTime={item.createdAt}>
{formatRelativeAge(new Date(item.createdAt).getTime(), nowMs)}
</time>
</span>
</span>
</Link>
)
})}
</>
)
return ( return (
<> <>
<Button <Button
@ -118,83 +174,30 @@ export const CampaignNotificationBell = ({
) : null} ) : null}
</Button> </Button>
{isMobile ? (
<Drawer open={open} onOpenChange={setOpen} showSwipeHandle> <Drawer open={open} onOpenChange={setOpen} showSwipeHandle>
<DrawerContent className="max-h-[85vh]"> <DrawerContent className="max-h-[85dvh]">
<DrawerHeader className="text-left"> <DrawerTitle className="sr-only">Notificações</DrawerTitle>
<DrawerTitle>Notificações</DrawerTitle> <DrawerDescription className="sr-only">Avisos da campanha para você</DrawerDescription>
<DrawerDescription>
{unreadCount > 0
? `${unreadCount} não ${unreadCount === 1 ? 'lida' : 'lidas'}`
: 'Tudo em dia'}
</DrawerDescription>
</DrawerHeader>
<div <div
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4 pb-2" className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4 pb-[max(1rem,env(safe-area-inset-bottom))]"
aria-busy={isPending} aria-busy={isPending}
> >
{loadError ? <p className="text-sm text-destructive">{loadError}</p> : null} {panelItems}
{!loadError && items.length === 0 && !isPending ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Nenhuma notificação por aqui.
</p>
) : null}
{items.map((item) => {
const Icon = notificationIcons[item.type]
const isUnread = !item.readAt
return (
<Link
key={item.id}
href={item.payload.href}
onClick={() => setOpen(false)}
className={cn(
'flex gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/50',
isUnread && 'border-primary/30 bg-primary/5',
)}
>
<span className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted">
<Icon className="size-4 text-muted-foreground" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium leading-snug">
{item.payload.title}
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{item.payload.detail}
</span>
<span className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{notificationTypeLabels[item.type]}</span>
<span aria-hidden>·</span>
<time dateTime={item.createdAt}>
{formatRelativeAge(new Date(item.createdAt).getTime(), nowMs)}
</time>
{isUnread ? (
<span className="ml-auto size-2 rounded-full bg-primary" aria-hidden />
) : null}
</span>
</span>
</Link>
)
})}
</div> </div>
<DrawerFooter className="border-t">
<Button
type="button"
variant="outline"
className="w-full"
disabled={isPending || unreadCount === 0}
onClick={handleMarkAllRead}
>
Marcar todas como lidas
</Button>
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
</DrawerFooter>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
) : (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[85dvh] w-[calc(100vw-2rem)] max-w-md overflow-y-auto p-4 sm:p-6">
<DialogTitle className="sr-only">Notificações</DialogTitle>
<DialogDescription className="sr-only">Avisos da campanha para você</DialogDescription>
<div className="flex flex-col gap-2" aria-busy={isPending}>
{panelItems}
</div>
</DialogContent>
</Dialog>
)}
</> </>
) )
} }

View file

@ -5,8 +5,7 @@ export const CAMPAIGN_PUSH_CONSENT_REQUIRED_MESSAGE =
export const CAMPAIGN_PUSH_SUBSCRIPTION_INVALID_MESSAGE = 'Dados de inscrição push inválidos.' export const CAMPAIGN_PUSH_SUBSCRIPTION_INVALID_MESSAGE = 'Dados de inscrição push inválidos.'
export const CAMPAIGN_NOTIFICATION_MARK_ALL_READ_ERROR_MESSAGE = export const CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE = 'Não foi possível carregar as notificações.'
'Não foi possível marcar as notificações como lidas.'
export const CAMPAIGN_PUSH_SUBSCRIBE_ERROR_MESSAGE = 'Não foi possível ativar os avisos push.' export const CAMPAIGN_PUSH_SUBSCRIBE_ERROR_MESSAGE = 'Não foi possível ativar os avisos push.'

View file

@ -23,7 +23,6 @@ export type NotificationListItem = {
id: number id: number
type: NotificationType type: NotificationType
payload: NotificationPayload payload: NotificationPayload
readAt: string | null
createdAt: string createdAt: string
} }

View file

@ -9,8 +9,6 @@ import type {
} from '@/lib/notificationContract' } from '@/lib/notificationContract'
import type { CampaignUser, Notification } from '@/payload-types' import type { CampaignUser, Notification } from '@/payload-types'
export type { NotificationListItem } from '@/lib/notificationContract'
const isNotificationPayload = (value: unknown): value is NotificationPayload => { const isNotificationPayload = (value: unknown): value is NotificationPayload => {
if (!value || typeof value !== 'object') return false if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown> const record = value as Record<string, unknown>
@ -28,7 +26,6 @@ const toNotificationListItem = (doc: Notification): NotificationListItem | null
id: doc.id, id: doc.id,
type: doc.type as NotificationType, type: doc.type as NotificationType,
payload: doc.payload, payload: doc.payload,
readAt: doc.readAt ?? null,
createdAt: doc.createdAt, createdAt: doc.createdAt,
} }
} }
@ -73,3 +70,49 @@ export const loadNotificationList = async (
.map((doc) => toNotificationListItem(doc)) .map((doc) => toNotificationListItem(doc))
.filter((item): item is NotificationListItem => item !== null) .filter((item): item is NotificationListItem => item !== null)
} }
const UNREAD_MARK_BATCH_LIMIT = 200
/**
* Marks the actor's unread notifications as read and returns how many were
* touched. Batched at `UNREAD_MARK_BATCH_LIMIT` rows — an inbox beyond that
* keeps unread leftovers that re-pop the badge on the next server render
* (pathological; the same cap the panel button used before C108). Writes go
* through the admin bypass on purpose, the same way the panel actions do:
* `notification.update` access is staff/admin-only (`canWriteNotifications`),
* and the caller (a server action) has already verified the actor from the
* session — this helper only ever targets `recipient: user.id`, so no other
* inbox can be touched through it. Idempotent: a second pass marks nothing.
*/
export const markAllNotificationsRead = async (
payload: Payload,
user: CampaignUser,
): Promise<number> => {
const unread = await payload.find({
collection: 'notification',
where: {
and: [{ recipient: { equals: user.id } }, { readAt: { exists: false } }],
},
depth: 0,
limit: UNREAD_MARK_BATCH_LIMIT,
pagination: false,
overrideAccess: true,
})
if (unread.docs.length === 0) return 0
const readAt = new Date().toISOString()
await Promise.all(
unread.docs.map((doc) =>
payload.update({
collection: 'notification',
id: doc.id,
data: { readAt },
depth: 0,
overrideAccess: true,
}),
),
)
return unread.docs.length
}

View file

@ -7,6 +7,7 @@ import config from '@/payload.config'
import { import {
countUnreadNotifications, countUnreadNotifications,
loadNotificationList, loadNotificationList,
markAllNotificationsRead,
} from '@/utilities/notification/notificationList' } from '@/utilities/notification/notificationList'
import { installCampaignFixtures } from '../helpers/campaignFixtures' import { installCampaignFixtures } from '../helpers/campaignFixtures'
@ -109,6 +110,72 @@ describe('notification access', () => {
await expect(countUnreadNotifications(payload, owner)).resolves.toBe(1) await expect(countUnreadNotifications(payload, owner)).resolves.toBe(1)
await expect(countUnreadNotifications(payload, other)).resolves.toBe(1) await expect(countUnreadNotifications(payload, other)).resolves.toBe(1)
}) })
it("marks only the actor's own unread notifications as read (C108)", async () => {
const fixtures = campaignFixtures()
const [owner, other] = await Promise.all([
fixtures.createCampaignUser('advisor'),
fixtures.createCampaignUser('advisor'),
])
const ownUnread = await payload.create({
collection: 'notification',
data: {
recipient: owner.id,
type: 'municipality_update',
payload: { title: 'Própria — não lida', detail: 'Detalhe', href: '/campanha' },
},
overrideAccess: true,
})
const ownAlreadyRead = await payload.create({
collection: 'notification',
data: {
recipient: owner.id,
type: 'new_supporter',
payload: { title: 'Própria — já lida', detail: 'Detalhe', href: '/campanha' },
readAt: new Date().toISOString(),
},
overrideAccess: true,
})
const otherUnread = await payload.create({
collection: 'notification',
data: {
recipient: other.id,
type: 'invite_accepted',
payload: { title: 'Alheia — não lida', detail: 'Detalhe', href: '/campanha' },
},
overrideAccess: true,
})
const markedCount = await markAllNotificationsRead(payload, owner)
expect(markedCount).toBe(1)
const ownAfter = await payload.findByID({
collection: 'notification',
id: ownUnread.id,
depth: 0,
overrideAccess: true,
})
expect(ownAfter.readAt).not.toBeNull()
const readOwn = await payload.findByID({
collection: 'notification',
id: ownAlreadyRead.id,
depth: 0,
overrideAccess: true,
})
expect(readOwn.readAt).not.toBeNull()
const untouched = await payload.findByID({
collection: 'notification',
id: otherUnread.id,
depth: 0,
overrideAccess: true,
})
expect(untouched.readAt).toBeNull()
// Idempotent: a second pass touches nothing.
await expect(markAllNotificationsRead(payload, owner)).resolves.toBe(0)
await expect(countUnreadNotifications(payload, owner)).resolves.toBe(0)
})
}) })
describe('pushSubscription access', () => { describe('pushSubscription access', () => {

View file

@ -0,0 +1,166 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { openAction, routerMock } = vi.hoisted(() => ({
openAction: vi.fn(),
routerMock: { refresh: vi.fn() },
}))
vi.mock('next/navigation', () => ({
useRouter: () => routerMock,
}))
vi.mock('@/app/(campaign)/campanha/actions/notifications', () => ({
openCampaignNotifications: openAction,
}))
import { CampaignNotificationBell } from '@/components/campaign/shell/CampaignNotificationBell'
import type { NotificationListItem } from '@/lib/notificationContract'
const now = new Date().toISOString()
const items: NotificationListItem[] = [
{
id: 1,
type: 'municipality_update',
payload: {
title: 'Nova atualização — Salvador ZE 01',
detail: 'Fato de campo registrado',
href: '/campanha/municipios/salvador-ze-01',
},
createdAt: now,
},
{
id: 2,
type: 'new_supporter',
payload: {
title: 'Convite aceito — Maria Souza',
detail: 'Novo apoiador',
href: '/campanha/apoiadores',
},
createdAt: now,
},
]
const renderBell = (unreadCount = 2) =>
render(<CampaignNotificationBell unreadCount={unreadCount} vapidPublicKey={null} />)
const matchMediaMock = vi.fn()
const stubMatchMedia = (matches: boolean) => {
matchMediaMock.mockImplementation((query: string) => ({
matches,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
vi.stubGlobal('matchMedia', matchMediaMock)
}
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.scrollIntoView = () => {}
})
beforeEach(() => {
stubMatchMedia(false)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
afterAll(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
})
describe('CampaignNotificationBell (C108 — read on open, clean panel, desktop dialog)', () => {
it('opens the panel, marks everything read in one call and clears the badge', async () => {
openAction.mockResolvedValue({ status: 'success', items, markedCount: 2 })
renderBell()
fireEvent.click(screen.getByRole('button', { name: '2 notificações não lidas' }))
const dialog = await screen.findByRole('dialog', { name: 'Notificações' })
expect(within(dialog).getByText('Nova atualização — Salvador ZE 01')).toBeTruthy()
expect(within(dialog).getByText('Convite aceito — Maria Souza')).toBeTruthy()
expect(openAction).toHaveBeenCalledTimes(1)
// Badge zeroes as soon as the open action lands — no extra click. The bell
// button sits outside the modal, so Radix marks it aria-hidden while the
// dialog is open and role queries cannot see it — assert on the badge text.
await waitFor(() => expect(screen.queryByText('2', { exact: true })).toBeNull())
})
it('re-syncs the sibling bell only when something was marked', async () => {
openAction.mockResolvedValue({ status: 'success', items, markedCount: 2 })
renderBell()
fireEvent.click(screen.getByRole('button', { name: '2 notificações não lidas' }))
await screen.findByRole('dialog', { name: 'Notificações' })
await waitFor(() => expect(routerMock.refresh).toHaveBeenCalledTimes(1))
})
it('renders no title bar, counter, close button or mark-all button', async () => {
openAction.mockResolvedValue({ status: 'success', items, markedCount: 2 })
renderBell()
fireEvent.click(screen.getByRole('button', { name: '2 notificações não lidas' }))
const dialog = await screen.findByRole('dialog', { name: 'Notificações' })
expect(within(dialog).queryByRole('button', { name: 'Marcar todas como lidas' })).toBeNull()
expect(within(dialog).queryByText(/não lidas|Tudo em dia/)).toBeNull()
// The only heading is the visually-hidden a11y title — the panel has no
// visible title bar.
expect(within(dialog).queryAllByRole('heading')).toHaveLength(1)
})
it('shows the empty state when there are no notifications', async () => {
openAction.mockResolvedValue({ status: 'success', items: [], markedCount: 0 })
renderBell(0)
fireEvent.click(screen.getByRole('button', { name: 'Notificações' }))
const dialog = await screen.findByRole('dialog', { name: 'Notificações' })
await within(dialog).findByText('Nenhuma notificação por aqui.')
expect(routerMock.refresh).not.toHaveBeenCalled()
})
it('surfaces the load error instead of marking anything', async () => {
openAction.mockResolvedValue({ message: 'Não foi possível carregar as notificações.' })
renderBell()
fireEvent.click(screen.getByRole('button', { name: '2 notificações não lidas' }))
const dialog = await screen.findByRole('dialog', { name: 'Notificações' })
await within(dialog).findByText('Não foi possível carregar as notificações.')
expect(routerMock.refresh).not.toHaveBeenCalled()
})
})
describe('CampaignNotificationBell (C108 — mobile bottom sheet)', () => {
it('renders the sheet with the same clean panel', async () => {
stubMatchMedia(true)
openAction.mockResolvedValue({ status: 'success', items, markedCount: 0 })
renderBell()
fireEvent.click(screen.getByRole('button', { name: '2 notificações não lidas' }))
const sheet = await screen.findByRole('dialog', { name: 'Notificações' })
expect(within(sheet).getByText('Nova atualização — Salvador ZE 01')).toBeTruthy()
expect(within(sheet).queryByRole('button', { name: 'Marcar todas como lidas' })).toBeNull()
expect(within(sheet).queryAllByRole('heading')).toHaveLength(1)
})
})