mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
07464b8810
commit
c3f10cce23
10 changed files with 492 additions and 158 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
if (markedCount > 0) {
|
||||||
await Promise.all(
|
revalidatePath('/campanha', 'layout')
|
||||||
unread.docs.map((doc) =>
|
}
|
||||||
payload.update({
|
|
||||||
collection: 'notification',
|
|
||||||
id: doc.id,
|
|
||||||
data: { readAt },
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
revalidatePath('/campanha', 'layout')
|
return { items, markedCount, message: 'Notificações atualizadas.' }
|
||||||
return { message: 'Todas as notificações foram marcadas como lidas.' }
|
|
||||||
},
|
},
|
||||||
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 = {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
<Drawer open={open} onOpenChange={setOpen} showSwipeHandle>
|
{isMobile ? (
|
||||||
<DrawerContent className="max-h-[85vh]">
|
<Drawer open={open} onOpenChange={setOpen} showSwipeHandle>
|
||||||
<DrawerHeader className="text-left">
|
<DrawerContent className="max-h-[85dvh]">
|
||||||
<DrawerTitle>Notificações</DrawerTitle>
|
<DrawerTitle className="sr-only">Notificações</DrawerTitle>
|
||||||
<DrawerDescription>
|
<DrawerDescription className="sr-only">Avisos da campanha para você</DrawerDescription>
|
||||||
{unreadCount > 0
|
<div
|
||||||
? `${unreadCount} não ${unreadCount === 1 ? 'lida' : 'lidas'}`
|
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4 pb-[max(1rem,env(safe-area-inset-bottom))]"
|
||||||
: 'Tudo em dia'}
|
aria-busy={isPending}
|
||||||
</DrawerDescription>
|
|
||||||
</DrawerHeader>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4 pb-2"
|
|
||||||
aria-busy={isPending}
|
|
||||||
>
|
|
||||||
{loadError ? <p className="text-sm text-destructive">{loadError}</p> : 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]
|
|
||||||
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>
|
|
||||||
|
|
||||||
<DrawerFooter className="border-t">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
disabled={isPending || unreadCount === 0}
|
|
||||||
onClick={handleMarkAllRead}
|
|
||||||
>
|
>
|
||||||
Marcar todas como lidas
|
{panelItems}
|
||||||
</Button>
|
</div>
|
||||||
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
|
</DrawerContent>
|
||||||
</DrawerFooter>
|
</Drawer>
|
||||||
</DrawerContent>
|
) : (
|
||||||
</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
166
tests/unit/campaignNotificationBell.unit.spec.tsx
Normal file
166
tests/unit/campaignNotificationBell.unit.spec.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue