diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index a520a8c9..c439478d 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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-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). diff --git a/docs/TECH-DEBT.md b/docs/TECH-DEBT.md index a00fbe0a..8fe3dda3 100644 --- a/docs/TECH-DEBT.md +++ b/docs/TECH-DEBT.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 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 | -| 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 | | `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 | diff --git a/docs/plans/sino-notificacoes-leitura-auto-modal-desktop-impl.md b/docs/plans/sino-notificacoes-leitura-auto-modal-desktop-impl.md new file mode 100644 index 00000000..26c37504 --- /dev/null +++ b/docs/plans/sino-notificacoes-leitura-auto-modal-desktop-impl.md @@ -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 diff --git a/src/app/(campaign)/campanha/actions/notifications.ts b/src/app/(campaign)/campanha/actions/notifications.ts index 3e26877f..bfadae4d 100644 --- a/src/app/(campaign)/campanha/actions/notifications.ts +++ b/src/app/(campaign)/campanha/actions/notifications.ts @@ -8,14 +8,13 @@ import { headers } from 'next/headers' import { getPayload } from 'payload' import { - CAMPAIGN_NOTIFICATION_MARK_ALL_READ_ERROR_MESSAGE, + CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE, CAMPAIGN_NOTIFICATION_THROW_SAFE_MESSAGES, CAMPAIGN_PUSH_CONSENT_REQUIRED_MESSAGE, CAMPAIGN_PUSH_SUBSCRIBE_ERROR_MESSAGE, CAMPAIGN_PUSH_SUBSCRIPTION_INVALID_MESSAGE, CAMPAIGN_PUSH_UNSUBSCRIBE_ERROR_MESSAGE, } from '@/lib/campaignNotificationCopy' -import type { NotificationListItem } from '@/lib/notificationContract' import { getCampaignUser } from '@/utilities/campaignAuth' import { requireCampaignPushConsent } from '@/utilities/campaignConsent' import { @@ -25,6 +24,7 @@ import { import { countUnreadNotifications, loadNotificationList, + markAllNotificationsRead, } from '@/utilities/notification/notificationList' import { getCampaignVapidPublicKey } from '@/utilities/notification/sendCampaignPush' @@ -51,50 +51,33 @@ export const loadNotificationBellData = async (): Promise => { - const user = await getCampaignUser() - if (!user) return [] - - const payload = await getPayload({ config }) - return loadNotificationList(payload, user) -} - -export const markAllCampaignNotificationsRead = async () => +/** + * The bell's open action (C108): loads the panel list AND marks everything + * 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 + * concurrently; the panel no longer reads per-item `readAt`, so the race is + * invisible to the UI (the badge is driven by `markedCount`, not the list). + */ +export const openCampaignNotifications = async () => runCampaignFormAction({ execute: async () => { const user = await getCampaignUser() if (!user) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE) const payload = await getPayload({ config }) - const unread = await payload.find({ - collection: 'notification', - where: { - and: [{ recipient: { equals: user.id } }, { readAt: { exists: false } }], - }, - depth: 0, - limit: 200, - pagination: false, - overrideAccess: true, - }) + const [items, markedCount] = await Promise.all([ + loadNotificationList(payload, user), + markAllNotificationsRead(payload, user), + ]) - 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, - genericMessage: CAMPAIGN_NOTIFICATION_MARK_ALL_READ_ERROR_MESSAGE, + safeMessages: [CAMPAIGN_AUTH_REQUIRED_MESSAGE], + genericMessage: CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE, }) type PushSubscribeInput = { diff --git a/src/components/campaign/shell/CampaignNotificationBell.tsx b/src/components/campaign/shell/CampaignNotificationBell.tsx index 905416d7..5d293e94 100644 --- a/src/components/campaign/shell/CampaignNotificationBell.tsx +++ b/src/components/campaign/shell/CampaignNotificationBell.tsx @@ -13,23 +13,16 @@ import { useRouter } from 'next/navigation' import { useCallback, useEffect, useState, useTransition } from 'react' import { - listCampaignNotifications, - markAllCampaignNotificationsRead, + openCampaignNotifications, type NotificationBellData, } from '@/app/(campaign)/campanha/actions/notifications' import { Button } from '@/components/ui/button' -import { - Drawer, - DrawerCloseButton, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, -} from '@/components/ui/Drawer' +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' +import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/ui/Drawer' +import { useIsMobile } from '@/hooks/use-mobile' +import { CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE } from '@/lib/campaignNotificationCopy' import type { NotificationListItem } from '@/lib/notificationContract' import { notificationTypeLabels, type NotificationType } from '@/lib/notificationContract' -import { cn } from '@/lib/utils' import { formatRelativeAge } from '@/utilities/formatRelativeAge' const notificationIcons: Record = { @@ -41,11 +34,18 @@ const notificationIcons: Record = { 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 = ({ unreadCount: initialUnreadCount, vapidPublicKey: _vapidPublicKey, }: CampaignNotificationBellProps) => { const router = useRouter() + const isMobile = useIsMobile() const [open, setOpen] = useState(false) const [unreadCount, setUnreadCount] = useState(initialUnreadCount) const [items, setItems] = useState([]) @@ -57,39 +57,30 @@ export const CampaignNotificationBell = ({ setUnreadCount(initialUnreadCount) }, [initialUnreadCount]) - const loadItems = useCallback(() => { + const loadPanel = useCallback(() => { startTransition(async () => { setLoadError(null) - try { - const nextItems = await listCampaignNotifications() - setItems(nextItems) - setUnreadCount(nextItems.filter((item) => !item.readAt).length) - } catch { - setLoadError('Não foi possível carregar as notificações.') + const result = await openCampaignNotifications() + if ('status' in result && result.status === 'success') { + setItems(result.items) + setUnreadCount(0) + if (result.markedCount > 0) { + // 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(() => { if (!open) return - loadItems() - }, [open, loadItems]) - - 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() - } - }) - } + loadPanel() + }, [open, loadPanel]) const unreadLabel = unreadCount === 0 @@ -98,6 +89,71 @@ export const CampaignNotificationBell = ({ ? '1 notificação não lida' : `${unreadCount} notificações não lidas` + const panelItems = ( + <> + {loadError ? ( +

+ {loadError} +

+ ) : null} + + {isPending && items.length === 0 ? ( + <> +
+ + + + + +
+
+ + + + + +
+ + ) : null} + + {!loadError && items.length === 0 && !isPending ? ( +

+ Nenhuma notificação por aqui. +

+ ) : null} + + {items.map((item) => { + const Icon = notificationIcons[item.type] + + return ( + 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" + > + + + + + {item.payload.title} + + {item.payload.detail} + + + {notificationTypeLabels[item.type]} + · + + + + + ) + })} + + ) + return ( <> - Fechar - - - + {panelItems} + + + + ) : ( + + + Notificações + Avisos da campanha para você +
+ {panelItems} +
+
+
+ )} ) } diff --git a/src/lib/campaignNotificationCopy.ts b/src/lib/campaignNotificationCopy.ts index 980f8d89..5752aab6 100644 --- a/src/lib/campaignNotificationCopy.ts +++ b/src/lib/campaignNotificationCopy.ts @@ -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_NOTIFICATION_MARK_ALL_READ_ERROR_MESSAGE = - 'Não foi possível marcar as notificações como lidas.' +export const CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE = 'Não foi possível carregar as notificações.' export const CAMPAIGN_PUSH_SUBSCRIBE_ERROR_MESSAGE = 'Não foi possível ativar os avisos push.' diff --git a/src/lib/notificationContract.ts b/src/lib/notificationContract.ts index 68196fdb..beebb8fb 100644 --- a/src/lib/notificationContract.ts +++ b/src/lib/notificationContract.ts @@ -23,7 +23,6 @@ export type NotificationListItem = { id: number type: NotificationType payload: NotificationPayload - readAt: string | null createdAt: string } diff --git a/src/utilities/notification/notificationList.ts b/src/utilities/notification/notificationList.ts index bf98dd92..70edef41 100644 --- a/src/utilities/notification/notificationList.ts +++ b/src/utilities/notification/notificationList.ts @@ -9,8 +9,6 @@ import type { } from '@/lib/notificationContract' import type { CampaignUser, Notification } from '@/payload-types' -export type { NotificationListItem } from '@/lib/notificationContract' - const isNotificationPayload = (value: unknown): value is NotificationPayload => { if (!value || typeof value !== 'object') return false const record = value as Record @@ -28,7 +26,6 @@ const toNotificationListItem = (doc: Notification): NotificationListItem | null id: doc.id, type: doc.type as NotificationType, payload: doc.payload, - readAt: doc.readAt ?? null, createdAt: doc.createdAt, } } @@ -73,3 +70,49 @@ export const loadNotificationList = async ( .map((doc) => toNotificationListItem(doc)) .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 => { + 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 +} diff --git a/tests/int/campaignNotifications.int.spec.ts b/tests/int/campaignNotifications.int.spec.ts index 526c2422..445b696f 100644 --- a/tests/int/campaignNotifications.int.spec.ts +++ b/tests/int/campaignNotifications.int.spec.ts @@ -7,6 +7,7 @@ import config from '@/payload.config' import { countUnreadNotifications, loadNotificationList, + markAllNotificationsRead, } from '@/utilities/notification/notificationList' import { installCampaignFixtures } from '../helpers/campaignFixtures' @@ -109,6 +110,72 @@ describe('notification access', () => { await expect(countUnreadNotifications(payload, owner)).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', () => { diff --git a/tests/unit/campaignNotificationBell.unit.spec.tsx b/tests/unit/campaignNotificationBell.unit.spec.tsx new file mode 100644 index 00000000..2c48e763 --- /dev/null +++ b/tests/unit/campaignNotificationBell.unit.spec.tsx @@ -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() + +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) + }) +})