Merge pull request #541 from fsolla/C100-pessoas-lista-unificada-da-campanha-rota-campanha-pesso

feat(C100): lista unificada de pessoas em /campanha/pessoas
This commit is contained in:
Francisco Solla 2026-08-10 14:22:57 -03:00 committed by GitHub
commit 9311c1623c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 3936 additions and 132 deletions

View file

@ -38,6 +38,9 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**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-10):** **C100** (Issue #495) — nova rota staff **`/campanha/pessoas`**: lista unificada de pessoas da campanha (lideranças, dobradinhas e staff) com **merge por `Contact`** — uma linha por pessoa física, capacidades como colunas (Nome com partido da dobradinha, Contato, Assessora, Lidera, Aliada em, Assessorado, Base, Ações). Merge em memória de 3 fontes com o where/access existente de cada domínio (`src/utilities/people/peopleData.ts` — fontes leadership/dobradinha/staff; staff source com `overrideAccess` justificado: campo `contact` identity-gated; escopo do assessor = linha com ≥1 município de capacidade na carteira; **todo staff com ficha entra, mesmo sem carteira** — gate humano 2026-08-09). Filtros na omnibox (capacidade multi OR, município, apoio, q) com URL canônica própria (`peopleListUrl.ts`, sem sort v1); **filtros salvos = 2º call site do B18** via factory compartilhada `createSavedFilterStore` (`municipalitySavedFilters` refatorado como delegado, API + storage key + eventos idênticos, suite unit existente guarda) + submenu no sidebar e no drawer "Mais". **Apagar pessoa** (`src/utilities/people/personDelete.ts` + `actions/person.ts`): manifest read-only → diálogo lista tudo (liderança c/ municípios, dobradinha c/ partido, votos, convites, apoiadores, atualizações de município, feeds de agenda, contas); cascata transacional com advisory lock `person-delete:<contact>` — ordem por FKs NOT NULL reais (pledges → invites → supporters → leadership → dobradinha → contas (hooks de passkeys/notificações) → ficha deletada ou anonimizada se signature/subscription a referenciarem); contas coordenação/candidato bloqueiam a pessoa inteira; advisor joins cascadeiam no delete da conta. UI: omnibox `edgeToEdge` (novo variant no chassis B127 — label sr-only, sem bordas, Limpar = círculo-X interno), cards mobile sem bordas com divisórias, "Salvar filtro" como icon button no header via `SetCampaignHeaderAction` (slot C94), colunas com column picker B17 (novo `CampaignListId 'pessoas'`), FABs globais intactos. Mobile: convite reusa `LeadershipInviteRowAction`; WhatsApp `whatsAppHrefForPhone`. Sem migration. Testes: unit (URL contract, merge/escopo/facets, storage factory), int (escopo assessor/unrestricted/leader, cascata completa + anonimização + conta protegida), e2e novo `campaignPeople` (smoke staff + leader redirect; pegou e corrigiu um `div` dentro de `p` no card mobile — erro de hidratação). Intenção [`pessoas-lista-unificada.md`](plans/pessoas-lista-unificada.md); impl [`pessoas-lista-unificada-impl.md`](plans/pessoas-lista-unificada-impl.md).
**Recently resolved (2026-08-09):** **B183** (Issue #501) — o auto-zoom do Safari iOS em inputs do PWA `/campanha` (font < 16px ao focar, escala que não volta no standalone) foi eliminado pela **causa**: uma regra CSS única em `styles.css` — `@media (pointer: coarse) and (hover: none)` + `[data-theme='campaign'] :is(input, select, textarea, [contenteditable='true']) { font-size: 1rem }` — cobrindo omnibox B127, Command search, native-select e textareas em qualquer orientação do iPhone (landscape chega a 932px de largura, o que descartou escopo por `max-width`). Unlayered vence as utilities `text-sm` sem `!important`; desktop (pointer fine) fica intocado; `Input`/`Textarea` do ui já eram `text-base md:text-sm`. Sem migration, access, Consent ou mudança de componente. E2E novo `tests/e2e/campaignIosInputZoom.e2e.spec.ts` pina o contrato: emulação `devices['iPhone 13']` → omnibox de `/campanha/municipios` computa **16px**; control desktop → permanece **14px**. Intenção [`pwa-ios-autozoom-input-foco.md`](plans/pwa-ios-autozoom-input-foco.md); impl [`pwa-ios-autozoom-input-foco-impl.md`](plans/pwa-ios-autozoom-input-foco-impl.md).
**Recently resolved (2026-08-09):** **C102** (Issue #498) — no mobile (<768px), nenhuma tela `/campanha` de staff mostra mais hamburger nem sheet de sidebar: a navegação staff mora na barra inferior + drawer "Mais". `CampaignMobileTopBar` esconde o `SidebarTrigger` via `useCampaignPageChromeRole` + `isStaffCampaignRole` (leader mantém — lockdown sem barra inferior); `CampaignSidebar` retorna `null` quando `isMobile && staff`, desmontando o Sheet; **B18 (filtros salvos de Municípios) migrou para o drawer "Mais"** — `MunicipalityNavSavedFilters` ganhou `variant: 'overflow'` (mesma store `useMunicipalitySavedFilters`, mesma lógica apagar/desfazer/sucessão de foco; no drawer o fallback de foco vira o link Perfil do footer) e `CampaignBottomNav` passou a renderizá-la entre destinos e footer (container com `overflow-y-auto`). **Bug pré-existente corrigido no caminho (D4):** o `<Toaster>` vivia dentro do wrapper `[contain:layout_paint]` do painel da AI — `contain: paint` vira containing block para `position: fixed` e cria stacking context próprio, prendendo o toast (z 999999999 do sonner) ABAIXO dos portais z-50 de drawer/sheet: qualquer toast com ação (ex. "Desfazer") era inalcançável com modal aberto; o Toaster foi movido para o root layout `(campaign)/layout.tsx` (irmão do `{children}`, direto no `<body>`). Testes: 2 unit novos na top bar (staff sem trigger / leader com trigger), 3 e2e novos (staff mobile sem trigger e sem dialog Sidebar; leader abre o sheet com "Meus contatos"; fluxo completo salvar→navegar→apagar→desfazer no drawer). Sem migration, access ou Consent. Intenção [`c102-mobile-sem-botao-sidebar.md`](plans/c102-mobile-sem-botao-sidebar.md); impl [`c102-mobile-sem-botao-sidebar-impl.md`](plans/c102-mobile-sem-botao-sidebar-impl.md).

View file

@ -0,0 +1,137 @@
# Impl: Pessoas — lista unificada da campanha (rota /campanha/pessoas)
Status: aprovado
Atualizado em: 2026-08-09
Issue: #495
Intenção: docs/plans/pessoas-lista-unificada.md
Appetite restante: ~3–4 dias eng (herdado — sem corte)
## Leitura da intenção
- **Outcome:** `/campanha/pessoas` lista e filtra todas as pessoas da campanha (lideranças, dobradinhas e staff) num lugar só; a mesma pessoa física em 2+ papéis = 1 linha (merge por `Contact`); 8 colunas (Nome, Contato, Assessora, Lidera, Aliada em, Assessorado, Base, Ações); omnibox com capacidade + município + filtros de domínio; filtros salvos no padrão B18; Ações = WhatsApp + convite (liderança) + Apagar (cascata destrutiva com confirmação explícita); sem apoiadores na v1; escopo de acesso preservado (assessor = carteira dele; leader lockdown).
- **O que NÃO negociar:** merge por `Contact` (nunca segundo cadastro de pessoa); leader lockdown (`gate: 'staff'`); apoiadores fora da v1; convite reusa `createCampaignInvite`/`LeadershipInviteRowAction` (consent fail-closed, 1:1, Res. TSE 23.610 art. 33); apagar precedido de confirmação que lista tudo; contas coordenação/candidato nunca apagáveis; nenhuma rota existente muda; escopo de acesso nunca alargado (cada query com o where do domínio).
- **O que reavaliar:** a hipótese da intenção citava B91 (busca global multi-entidade) como precedente de agregação — rejeitado para esta superfície (ver D0); "utilizadores por domínio existentes" existem para leitura individual, mas **nenhum loader existente faz union por Contact** — precisa loader novo de merge (D1); "especializar filtros salvos (2º call site)" — confirmado, com extração mínima do storage (D5).
## Decisões de engenharia
### D0 — Superfície de busca: omnibox de lista, não B91
Opções: A) reusar a máquina de busca global (`CampaignHomeSearch`/`home-search`) | B) omnibox de lista (`CampaignListOmnibox` + adaptador de domínio, padrão Municípios/Lideranças).
Recomendação: **B** — a rota é uma lista com URL canônica; o omnibox de lista é o precedente do sistema de listas (Pass 2 W1) e dá filtros salvos de graça. B91 é fortemente acoplado ao Início (context providers, exclude-current-entity, debounce 250ms, scope merge) e resolveria só a busca, não os facets.
Rejeitada: A porque acopla a rota a contextos do Início e não participa do URL state.
### D1 — Merge por `Contact`: fontes por domínio + união em memória no servidor
Opções:
- A) query em `contact` (com o escopo de `canReadContacts`) e resolver joins por pessoa;
- B) **três fontes por domínio** (leadership, stateDeputy, campaignUser-com-carteira), cada uma com o where/access existente do domínio, e **união em memória por `contact.id`** com escopo por capacidade (recomendada);
- C) UNION SQL via drizzle.
Recomendação: **B** — cada fonte já tem loader + where + access pronto (`buildLeadershipListWhere`, `loadStateDeputyListPageData`-shape, `municipalityIdsByAdvisorIds`); a união em memória é uma função pura testável; volume atual (centenas de lideranças, dezenas de dobradinhas/staff) cabe sem paginação no nível de fonte.
Rejeitadas: A porque `canReadContacts` inclui fichas de apoiadores (v1 exclui) e exige pós-filtro que quebra a semântica de paginação/facets; C porque contorna a camada de access do Payload e duplica o where de cada domínio.
**Merge (puro):** `mergePeopleSources({ leadershipRows, deputyRows, staffAccounts, municipalityIdsBy<Fonte> })` → `PeopleRow[]` — uma linha por `contactID` com capacidades preenchidas: `leadership?` (1, contato UNIQUE), `deputy?` (1, contato UNIQUE), `staff[]` (N contas por ficha — dedupe multpapel). Entram pessoas com **qualquer papel**: liderança, dobradinha ou conta staff com ficha (mesmo sem carteira — gate do humano em 2026-08-09, ver D2). Nome/telefone/e-mail/cidade vêm da **ficha** (depth-1 populate de `contact`), partido de `stateDeputy.party` (única fonte, no máximo uma dobradinha por ficha).
### D2 — Access: escopo por capacidade, nunca alargado
- **Leadership:** `payload.find` com `user` + `overrideAccess: false` → `canReadLeadership` aplica o where de carteira do assessor; filtros `q` (contact.name contains), `supportStatus`, `municipalities in` no where.
- **Dobradinha:** `user` + `overrideAccess: false` → acesso staff-wide existente; municípios da dobradinha via `municipalityIdsByStateDeputyIds` (já existe).
- **Staff (fonte de Assessora) — regra de inclusão (gate do humano 2026-08-09):** **toda conta staff** (`advisor` | `coordinator` | `candidate`) com ficha (`contact` ≠ null) entra na lista **mesmo sem carteira** — ser staff é papel; a carteira só preenche a coluna Assessora (vazia = "—"). `overrideAccess: true` **justificado e documentado** — precedente `loadAdvisorListPageData` (`/assessores`, mesmo padrão) e `canReadCampaignUserPhone` (regra same-municipality advisor); o campo `contact` de `campaignUser` tem field access `canReadCampaignUserIdentity` (self/admin) e o depth-1 populate seria cortado para assessor. O escopo de leitura é imposto **pelo merge** (D3), não pela collection access. Contas legadas sem ficha não têm identidade para o merge → fora da v1 (documentado).
- **Contato:** lido via depth-1 das fontes (nunca via `contact` collection access — `canReadContacts` não alcança ficha puramente staff; a ficha do staff entra porque a pessoa aparece por papel staff, mesma regra de `canReadCampaignUserPhone`).
### D3 — Escopo do assessor (quem vê): carteira só
Opções: A) união literal dos wheres (assessor veria TODAS as dobradinhas — staff-wide hoje) | B) **restringir a linha a pessoas com ≥1 município de capacidade na carteira do assessor** (recomendada).
Recomendação: **B** — o aceite trava "assessor vê só a carteira dele"; a dobradinha fora da carteira não é operação dele. Aplicado em memória após o merge (linha incluída sse `(lidera ∪ aliada ∪ carteira) ∩ portfolio ≠ ∅`). Consequência consistente com a regra de inclusão: staff **sem carteira** não tem município de capacidade → visível apenas a unrestricted (coordenação/candidato); `/dobradinhas` permanece staff-wide intacta (escopo é por página, não por coleção).
Rejeitada: A porque contradiz o aceite ("só a carteira dele").
### D4 — Apagar pessoa: cascata transacional com manifest de confirmação
Duas actions (`src/app/(campaign)/campanha/actions/person.ts`), só `isCampaignUnrestricted` (coordenação/candidato — ação destrutiva transversal):
1. **`getPersonDeleteManifest`** (read-only): enumera `{ contact, leaderships (com municípios), stateDeputies (partido), pledgeCount, inviteCount, supporterCount, accounts (nome+role), hasProtectedAccount (role coordinator|candidate), fichaBlockedByOtherJoins (signature/subscription), fichaWillBeAnonymized }` — o diálogo lista exatamente isto antes de confirmar (padrão preview do import de CSV, sem staging/token: o manifest é derivado do dado vivo).
2. **`deletePerson`** em `withPayloadTransaction`:
- `reloadUnrestrictedActor` + assert; advisory lock `person-delete:{contactID}` (padrão `acquireTextAdvisoryLocks`).
- Re-enumeração fresca dentro da tx; `hasProtectedAccount` → abortar com mensagem (conta coordenação/candidato nunca apagável → a pessoa inteira está protegida).
- **Ordem de delete (FKs NOT NULL + `ON DELETE set null` = bloqueiam):** `votePledge` (leadership in ids) → `campaignInvite` (leadership in ids; cobre `created_by_id` NOT NULL) → `supporter` (contact = id) → `leadership` (contact = id) → `stateDeputy` (contact = id) → `campaignUser` (contact = id; o `beforeDelete` existente limpa passkeys + notificações) → limpeza de vínculos de assessoria dos usuários deletados (`municipality_rels`/`activity_rels` — verificar FK em execução; `leadership_rels`/`state_deputy_rels` já são `ON DELETE cascade`) → ficha `contact`: **delete** se nenhum outro join restar (signature/subscription), senão **anonimizar** (tombstone, precedente `removeSupporterData`). `leadership.user`, `campaign_demand.leadership_id`, `activity.leadership_id` fazem SET NULL sozinhos.
- Contrato LGPD: `removeSupporterData` (anônima ficha quando sem joins) é o precedente da casa; aqui o gesto é de remoção total, então a ficha é deletada quando possível, anonimizada quando outro join público a referencia (declarado no diálogo).
- UI: `DeletePersonButton` (AlertDialog, fetch do manifest ao abrir, lista o que será removido, confirmação destrutiva, `router.refresh` + toast no fim) — padrão `RemoveSupporterDataButton`.
### D5 — Filtros salvos: 2º call site do padrão B18 (sem genericizar)
Opções: A) copiar o módulo de storage (155 linhas) para Pessoas | B) **extrair factory mínima `createSavedFilterStore({ storageKey, isHrefValid })`** com contrato B18 preservado, e reescrever `municipalitySavedFilters.ts` como delegado (API pública idêntica; suite `municipalitySavedFilters.unit.spec.ts` guarda) | C) sistema genérico de filtros salvos.
Recomendação: **B** — storage é mecânica pura com 2 call sites reais; o veto FD2 é sobre o SISTEMA (server-sync, config-driven), não sobre o primitivo de storage. `MAX_ENTRIES=12`, `MAX_NAME_LENGTH=60`, rename-no-teto, sort alfabético, dedupe por href, snapshot caching, evento custom derivado da key, limpeza da legacy key — tudo preservado e coberto por testes.
Rejeitadas: A porque duplica mecânica com semântica sutil (rename no teto, evento entre ilhas); C porque o veto FD2 é explícito e o padrão B18 é o teto.
Hooks/controles/submenu permanecem **especializados por página** (hrefs, i18n, active-match com `isSameListHref` ignorando `page`): `usePeopleSavedFilters` + `useActivePeopleSavedFilter`, `SavePeopleFilterControl`, `PeopleNavSavedFilters` (variants sidebar + overflow). Se a refatoração do `MunicipalityNavSavedFilters` para componente presentacional compartilhado ficar neutra em comportamento e verde no e2e, extrai-se; senão, duplica-se o submenu (JSX fino).
### D6 — URL state e canonicalização
`src/utilities/people/peopleListUrl.ts` no contrato `campaignListUrl.ts` (parse/serialize/resolve/buildHref), basePath `/campanha/pessoas`. Estado: `{ page, q, capacity (multi — chips do omnibox, OR dentro do facet, como os demais filtros; ausência = todas), municipalities (multi), supportStatus (multi) }`. Filtros em memória pós-merge (capacidade, município, supportStatus); `q` + `supportStatus` também no where da fonte leadership (contém o volume); `q` no where de dobradinha/staff. `PEOPLE_PAGE_SIZE` (padrão 25, precedente das demais listas).
### D7 — Facets da omnibox
Opções de origem: A) facets derivados do conjunto filtrado atual (como a lista de Municípios deriva do where) | B) **facet options = municípios e status presentes no conjunto mergeado (escopo-restrito), sem reagir aos filtros ativos** (recomendada).
Recomendação: **B** — os facets do merge em memória são baratos de derivar do conjunto escopo-restrito; reagir ao filtro ativo é polish de v1 posterior. Municípios rotulados via `loadMunicipalityPortfolioIndex` + `resolvedPortfolioEntriesById` (precedente lideranças); para assessor, o universo de municípios já é a carteira.
Rejeitada: A porque exige re-derivar facets a cada mudança de estado sem ganho perceptível na v1.
### D8 — Colunas, partido e células
- **Nome:** texto da ficha; sufixo `(partido)` quando a pessoa tem dobradinha (única fonte `stateDeputy.party`). Sem link próprio na v1 (sem página de pessoa).
- **Contato:** telefone em destaque + e-mail segunda linha discreta; sem telefone, e-mail como linha principal; sem nenhum → `—`.
- **Assessora / Lidera / Aliada em:** chips de município truncados "+N" (`truncatedNamesLabel` de `campaignListUrl.ts`, padrão B159) — leitura pura, sem editor (sem edição inline nesta entrega). Facet "É assessora" = **coluna Assessora preenchida** (carteira não-vazia), como diz o canvas; staff sem carteira aparece em "Todas", não em "É assessora".
- **Assessorado:** nomes de assessores responsáveis (união `leadership.advisors` + `stateDeputy.advisors`, via `loadCampaignUserNamesByIds`).
- **Base:** `contact.city`.
- **Ações:** WhatsApp (`whatsAppHrefForPhone` da ficha — só com telefone), Convite (`LeadershipInviteRowAction` reusada, só quando há engajamento de liderança), Apagar (só unrestricted, `DeletePersonButton`).
- **Column picker:** novo `CampaignListId 'pessoas'` em `CAMPAIGN_LIST_IDS` (B17).
- **Mobile (gate do humano 2026-08-09):** visual **edge-to-edge sem bordas** — omnibox sem label e sem borda, ocupando a largura toda, com a ação "Limpar" como **ícone circular com X no canto interno direito**; o feed de cards é separado por **linhas horizontais** (divide-y), não por cards bordados (padrão das outras listas); "Salvar filtro" vira **icon button no header** (chrome da página). FABs de quick actions (`CampaignQuickActionsHost` via `CampaignAppScrollChrome`) e Sollinha AI (`CampaignAISidebarShell`) são mounts globais do layout `(app)` — persistem automaticamente; verificar no craft que omnibox edge-to-edge e FABs não se sobrepõem (z-index). Desktop mantém o padrão das listas (omnibox + `CampaignTable`).
### D9 — Rota e nav
- `src/app/(campaign)/campanha/(app)/pessoas/page.tsx` + `loading.tsx`; gate `requireCampaignPageActor({ gate: 'staff' })` (leader → `/campanha/contatos`, lockdown); metadata via catálogo de páginas (adicionar entrada `pessoas`).
- Sidebar: `Pessoas` em `staffNav` após `Dobradinhas`; constante `PEOPLE_NAV_HREF` (padrão `MUNICIPALITY_NAV_HREF`) para o submenu de filtros salvos em `CampaignSidebar` + `CampaignBottomNav` (overflow); bottom nav primária intacta (Pessoas cai no "Mais" automaticamente).
## Componentes / mudanças
- **`people/`** (novo domínio, espelhando `municipality/`):
- `src/utilities/people/peopleListUrl.ts` — estado + parse/serialize/resolve/buildHref.
- `src/utilities/people/peopleData.ts` — `loadPeopleListPageData` (3 fontes + merge + escopo + facets + paginação), `PeopleRowViewModel`, `mergePeopleSources` (puro).
- `src/utilities/people/peopleSavedFilters.ts` (via factory D5) + `src/utilities/people/peopleFilters.ts` (toggle/clear/summary/hrefs) + `peopleOmnibox.ts` (chips/suggestions/apply).
- **Actions:** `src/app/(campaign)/campanha/actions/person.ts` (`getPersonDeleteManifest`, `deletePerson`); schemas em `src/lib/schemas/personDelete.ts`.
- **Componentes:** `src/components/campaign/people/PeopleFilters.tsx`, `PeopleListTable`/colunas (ou inline no page), `PeopleListMobileCards.tsx`, `DeletePersonButton.tsx`, `SavePeopleFilterControl.tsx`, `PeopleNavSavedFilters.tsx`, hooks `usePeopleSavedFilters.ts`.
- **Shell:** `nav.ts` (`Pessoas` + `PEOPLE_NAV_HREF`), `CampaignSidebar.tsx`, `CampaignBottomNav.tsx` (slot do submenu), `campaignColumnVisibility.ts` (`pessoas`), catálogo de páginas/metadata.
- **Storage:** `src/utilities/campaignSavedFilterStore.ts` (factory) + refactor delegado de `municipalitySavedFilters.ts`.
- **Migration:** nenhuma — sem mudança de schema (cascata é camada de dados; FKs existentes mapeadas em D4).
- **Access/Consent:** sem chave nova; convite reusa `lideranca-autopreenchimento` (fail-closed, intocado); overrides documentados em D2/D4.
- **UI:** Impeccable C — rota nova + tabela + omnibox + filtros salvos; shape → craft → critique → polish; tokens `data-theme='campaign'`, shells do sistema de listas.
## Dados → forma
Não aplicável (vínculos, não métricas — intenção Dados: N/A). Nenhum agregado por pessoa na superfície; votos só aparecem como contagem no manifest de apagar.
## Fases verificáveis
1. **Tracer (schema/server mínimo):** `peopleListUrl` + `mergePeopleSources` + `loadPeopleListPageData` (3 fontes + escopo) + rota com colunas Nome/Contato — valida canonicalização, gate e escopo de assessor cedo (unidades + int).
2. **Server completo:** filtros/facets/paginação + `personDelete` (manifest + cascata) + testes de domínio (unit merge/url; int escopo + cascata).
3. **UI:** filtros/omnibox, 8 colunas, mobile cards, ações (WhatsApp/Convite/Apagar), column picker, filtros salvos (factory + controle + submenu), sidebar.
4. **Gates + docs:** `pnpm gate:fast` na iteração; entrega com `pnpm push`; entrada no `docs/CHANGELOG-AGENTS.md`; commit do `*-impl.md`.
## Rabbit holes / Não escopo (engenharia)
- Sistema genérico de filtros salvos (veto FD2); merge de `Contact` duplicados (v1: linhas duplicadas visíveis); "transferir engajamentos" (pós-eleição); edição inline de células; página de detalhe de pessoa; RecentVisitTracker (polish posterior); e2e amplo (smoke da rota + fluxo de apagar via int, não e2e).
- Não criar loader genérico "busca de pessoa" para fora desta rota (1 call site).
## Riscos e mitigação
- **Surpresas de FK na cascata** (`municipality_rels`/`activity_rels` podem ser `SET NULL` e exigir limpeza explícita; ordens de delete erradas = 500): mapear FKs reais em execução (`migrate:status`/drizzle inspect) antes de escrever a cascata; int test do caminho completo (pessoa com todos os papéis + passkey) valida.
- **Refactor do B18 storage regredir comportamento**: API pública idêntica + suite unit existente + e2e de filtros salvos existente como rede de segurança; manter storage key e eventos idênticos.
- **Semântica de access do staff source divergir do esperado**: overrides documentados + justificados com precedentes citados (D2); int test com assessor verifica que vê só a carteira dele e que contas fora dela não vazam.
- **Volume** (pessoas × municípios nas células): chips truncados "+N"; sem paginação no nível de fonte (merge em memória com volume atual ~centenas).
- **`NEXT_PUBLIC_SITE_URL`** (convite): fluxo existente, sem mudança — checklist de deploy já cobre.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (merge 1 linha/pessoa; 8 colunas; facets; filtros salvos B18 2º call site; WhatsApp/invite/delete com confirmação; sem apoiadores; roteamento intacto)
- [x] Invariantes AGENTS/engineering-standards (transação + `req.transactionID` em toda escrita multi-collection; `overrideAccess` justificado e documentado; pessoa = join com `Contact`; consent fail-closed intocado; identif. em inglês / copy pt-BR; gates `staff`)
- [x] Testes previstos: unit (`peopleListUrl` canonicalização; `mergePeopleSources` dedupe/escopo/partido; factory de storage), int (`loadPeopleListPageData` escopo assessor/unrestricted; cascata completa `deletePerson` incl. passkeys e ficha deletada/anonimizada; manifest; conta protegida bloqueia), e2e smoke (rota + filtro + salvar recorte)

View file

@ -121,6 +121,15 @@ export const E2E_AFFECTED_MANIFEST = [
'campaignSollinhaWidth',
],
},
{
prefixes: [
`${CAMPAIGN_APP}/pessoas`,
'src/components/campaign/people',
'src/utilities/people',
'src/utilities/campaignSavedFilterStore',
],
specs: ['campaignPeople'],
},
// Domains without a dedicated e2e family still wake campaign home smoke so
// the affected classifier cannot return mode=none on an unmapped domain dir.
{

View file

@ -0,0 +1,20 @@
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Skeleton } from '@/components/ui/skeleton'
/** List-shaped skeleton for /pessoas. */
export default function PeopleLoading() {
return (
<CampaignPageShell aria-label="Carregando pessoas" aria-busy="true">
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-5 w-96 max-w-full" />
</div>
<div className="flex flex-wrap gap-3">
<Skeleton className="h-11 w-full max-w-md" />
<Skeleton className="h-11 w-40" />
<Skeleton className="h-11 w-40" />
</div>
<Skeleton className="h-96 w-full rounded-xl" />
</CampaignPageShell>
)
}

View file

@ -0,0 +1,408 @@
import config from '@payload-config'
import { MessageCircleIcon, SearchXIcon } from 'lucide-react'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import type { ReactNode } from 'react'
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton'
import { PeopleFilters } from '@/components/campaign/people/PeopleFilters'
import { PeopleListPageChrome } from '@/components/campaign/people/PeopleListPageChrome'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button, buttonVariants } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
import {
resolvedPortfolioEntriesById,
type ResolvedPortfolioEntry,
} from '@/lib/municipalityPortfolio'
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
import { cn } from '@/lib/utils'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
import { loadPeopleListPageData, type PeopleRowViewModel } from '@/utilities/people/peopleData'
import {
buildPeopleFilterHref,
clearPeopleListFilters,
type PeopleFilterOption,
} from '@/utilities/people/peopleListFilters'
import { buildPeopleListHref, resolvePeopleListUrl } from '@/utilities/people/peopleListUrl'
export const metadata = campaignPageMetadataFromCatalog('pessoas')
type PeoplePageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
const PeopleListEmptyState = ({ hasFilters, scoped }: { hasFilters: boolean; scoped: boolean }) => (
<Empty className="min-h-56">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma pessoa neste recorte</EmptyTitle>
<EmptyDescription>
Ajuste a busca e os filtros para ver as pessoas da campanha.
{scoped ? ' Você só vê as pessoas dos seus municípios.' : null}
</EmptyDescription>
</EmptyHeader>
{hasFilters ? (
<EmptyContent>
<CampaignTransitionAnchor
href={buildPeopleFilterHref(clearPeopleListFilters())}
replace
scroll={false}
className={cn(buttonVariants({ variant: 'outline' }), 'min-h-11')}
>
Limpar busca e filtros
</CampaignTransitionAnchor>
</EmptyContent>
) : null}
</Empty>
)
const PeopleMunicipalityCell = ({
ids,
index,
}: {
ids: readonly number[]
index: ReadonlyMap<number, ResolvedPortfolioEntry>
}) => {
if (!ids.length) return <span className="text-muted-foreground">—</span>
const names = ids
.map((id) => index.get(id)?.name)
.filter((name): name is string => name !== undefined)
if (!names.length) return <span className="text-muted-foreground">—</span>
const visible = names.slice(0, 2)
const rest = names.length - visible.length
return (
<div className="flex flex-wrap gap-1">
{visible.map((name) => (
<Badge key={name} variant="outline" className="max-w-full truncate font-normal">
{name}
</Badge>
))}
{rest > 0 ? (
<Badge variant="outline" className="font-normal text-muted-foreground">
+{rest}
</Badge>
) : null}
</div>
)
}
const peopleColumns = ({
municipalityIndex,
canDelete,
}: {
municipalityIndex: ReadonlyMap<number, ResolvedPortfolioEntry>
canDelete: boolean
}): Array<CampaignTableColumn<PeopleRowViewModel>> => [
{
id: 'name',
label: 'Nome',
mandatory: true,
cell: (row) => (
<span className="font-medium">
{row.name}
{row.party ? <span className="text-muted-foreground"> ({row.party})</span> : null}
</span>
),
},
{
id: 'contact',
label: 'Contato',
cellClassName: 'whitespace-normal',
cell: (row) => (
<span className="flex flex-col">
{row.phone ? (
<span className="font-medium tabular-nums">{formatBrazilianPhoneInput(row.phone)}</span>
) : null}
<span className={row.phone ? 'text-xs text-muted-foreground' : undefined}>
{row.email ?? '—'}
</span>
</span>
),
},
{
id: 'assessora',
label: 'Assessora',
cellClassName: 'max-w-56 whitespace-normal',
cell: (row) => (
<PeopleMunicipalityCell ids={row.assessoraMunicipalityIDs} index={municipalityIndex} />
),
},
{
id: 'lidera',
label: 'Lidera',
cellClassName: 'max-w-56 whitespace-normal',
cell: (row) => (
<PeopleMunicipalityCell ids={row.leadershipMunicipalityIDs} index={municipalityIndex} />
),
},
{
id: 'aliada',
label: 'Aliada em',
cellClassName: 'max-w-56 whitespace-normal',
cell: (row) => (
<PeopleMunicipalityCell ids={row.deputyMunicipalityIDs} index={municipalityIndex} />
),
},
{
id: 'assessorado',
label: 'Assessorado',
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
cell: (row) => row.assessoradoNames.join(', ') || '—',
},
{
id: 'base',
label: 'Base',
cell: (row) => row.city ?? '—',
},
{
id: 'actions',
label: 'Ações',
head: (
<CampaignTableHead align="right">
<span className="sr-only">Ações</span>
</CampaignTableHead>
),
cellClassName: 'text-right',
cell: (row) => {
const whatsAppHref = whatsAppHrefForPhone(row.phone)
return (
<div className="inline-flex items-center justify-end gap-1">
{row.leadershipID !== null ? (
<LeadershipInviteRowAction
leadershipID={row.leadershipID}
name={row.name}
hasValidPhone={whatsAppHref !== null}
/>
) : null}
{whatsAppHref ? (
<Button asChild variant="ghost" size="icon" className="size-10">
<a
href={whatsAppHref}
target="_blank"
rel="noopener noreferrer"
aria-label={`Enviar WhatsApp para ${row.name}`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</a>
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="size-10"
disabled
aria-label={`WhatsApp indisponível — ${row.name} sem celular`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</Button>
)}
{canDelete ? (
<DeletePersonButton personName={row.name} contactId={row.contactID} />
) : null}
</div>
)
},
},
]
const PeopleMobileCards = ({
rows,
municipalityIndex,
canDelete,
empty,
}: {
rows: readonly PeopleRowViewModel[]
municipalityIndex: ReadonlyMap<number, ResolvedPortfolioEntry>
canDelete: boolean
empty: ReactNode
}) => (
<ul data-view="mobile-cards" className="flex flex-col divide-y md:hidden">
{rows.length === 0 ? (
<li className="py-4">{empty}</li>
) : (
rows.map((row) => {
const whatsAppHref = whatsAppHrefForPhone(row.phone)
const capacities: Array<{ label: string; ids: readonly number[] }> = [
{ label: 'Assessora', ids: row.assessoraMunicipalityIDs },
{ label: 'Lidera', ids: row.leadershipMunicipalityIDs },
{ label: 'Aliada em', ids: row.deputyMunicipalityIDs },
]
return (
<li key={row.contactID} className="flex flex-col gap-2 py-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">
{row.name}
{row.party ? <span className="text-muted-foreground"> ({row.party})</span> : null}
</p>
<p className="truncate text-xs text-muted-foreground">
{row.phone
? formatBrazilianPhoneInput(row.phone)
: row.email
? row.email
: 'Sem contato registrado'}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
{row.leadershipID !== null ? (
<LeadershipInviteRowAction
leadershipID={row.leadershipID}
name={row.name}
hasValidPhone={whatsAppHref !== null}
/>
) : null}
{whatsAppHref ? (
<Button asChild variant="ghost" size="icon" className="size-10">
<a
href={whatsAppHref}
target="_blank"
rel="noopener noreferrer"
aria-label={`Enviar WhatsApp para ${row.name}`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</a>
</Button>
) : null}
{canDelete ? (
<DeletePersonButton personName={row.name} contactId={row.contactID} />
) : null}
</div>
</div>
{capacities.some((capacity) => capacity.ids.length > 0) ? (
<div className="flex flex-col gap-1.5">
{capacities.map((capacity) =>
capacity.ids.length > 0 ? (
<div key={capacity.label} className="flex flex-wrap items-center gap-1 text-xs">
<span className="font-medium text-muted-foreground">{capacity.label}:</span>
<PeopleMunicipalityCell ids={capacity.ids} index={municipalityIndex} />
</div>
) : null,
)}
</div>
) : null}
{row.assessoradoNames.length > 0 ? (
<p className="text-xs text-muted-foreground">
Assessorado por {row.assessoradoNames.join(', ')}
</p>
) : null}
{row.city ? <p className="text-xs text-muted-foreground">Base: {row.city}</p> : null}
</li>
)
})
)}
</ul>
)
export default async function PeoplePage({ searchParams }: PeoplePageProps) {
const rawSearchParams = await searchParams
const canonicalUrl = resolvePeopleListUrl(rawSearchParams)
if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref)
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff' }),
getPayload({ config }),
])
const [columnVisibility, listData, municipalityIndex] = await Promise.all([
readCampaignColumnVisibility('pessoas'),
loadPeopleListPageData(payload, user, canonicalUrl.state),
loadMunicipalityPortfolioIndex(),
])
const resolvedUrl = resolvePeopleListUrl(rawSearchParams, listData.totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
const resolvedIndex = resolvedPortfolioEntriesById(municipalityIndex)
const municipalityFilterOptions: PeopleFilterOption[] = listData.filterFacets.municipalityIDs
.map((id) => {
const entry = resolvedIndex.get(id)
return entry ? { value: String(id), label: entry.name } : null
})
.filter((option): option is PeopleFilterOption => option !== null)
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
const canDelete = isUnrestrictedCampaignRole(user.role)
const columns = peopleColumns({ municipalityIndex: resolvedIndex, canDelete })
const hasFilters = Boolean(
state.q || state.capacities?.length || state.municipalities?.length || state.statuses?.length,
)
return (
<CampaignPageShell>
<PeopleListPageChrome />
<CampaignListPendingBoundary>
<PeopleFilters
state={state}
municipalityFilterOptions={municipalityFilterOptions}
trailing={
<CampaignColumnPickerTrailing
columnVisibility={columnVisibility}
columns={toCampaignColumnPickerColumns(columns)}
/>
}
/>
<CampaignListResults>
<PeopleMobileCards
rows={listData.rows}
municipalityIndex={resolvedIndex}
canDelete={canDelete}
empty={
<PeopleListEmptyState hasFilters={hasFilters} scoped={user.role === 'advisor'} />
}
/>
<CampaignTable
className="hidden md:block"
caption="Uma linha por pessoa — cada pessoa pode assessorar, liderar e ser dobradinha ao mesmo tempo."
columns={columns}
columnVisibility={columnVisibility}
rows={listData.rows}
rowKey={(row) => row.contactID}
empty={
<PeopleListEmptyState hasFilters={hasFilters} scoped={user.role === 'advisor'} />
}
/>
{listData.rows.length ? (
<CampaignListFooter
totalDocs={listData.totalDocs}
singular="pessoa"
plural="pessoas"
page={state.page}
totalPages={listData.totalPages}
hrefForPage={(page) => buildPeopleListHref(state, page)}
/>
) : null}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,36 @@
'use server'
import {
PERSON_DELETE_FORBIDDEN_MESSAGE,
personDeleteInputSchema,
} from '@/lib/schemas/personDelete'
import {
getCampaignActionContext,
reloadUnrestrictedActor,
} from '@/utilities/campaignActionContext'
import { deletePersonRecord, loadPersonDeleteManifest } from '@/utilities/people/personDelete'
/**
* C100 "Apagar pessoa" server actions. Both are coordinator/candidate-only
* (`reloadUnrestrictedActor`): the cascade is transversal, wider than any
* advisor carteira.
*/
/** The confirmation dialog lists this verbatim before offering the destroy. */
export const getPersonDeleteManifestAction = async (input: unknown) => {
const data = personDeleteInputSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
await reloadUnrestrictedActor(payload, actor, PERSON_DELETE_FORBIDDEN_MESSAGE)
return loadPersonDeleteManifest(payload, data.contactId)
}
export const deletePersonAction = async (input: unknown) => {
const data = personDeleteInputSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
const currentActor = await reloadUnrestrictedActor(
payload,
actor,
PERSON_DELETE_FORBIDDEN_MESSAGE,
)
return deletePersonRecord(payload, currentActor, data.contactId)
}

View file

@ -0,0 +1,261 @@
'use client'
import { Trash2Icon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useRef, useState } from 'react'
import { toast } from 'sonner'
import {
deletePersonAction,
getPersonDeleteManifestAction,
} from '@/app/(campaign)/campanha/actions/person'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/AlertDialog'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import {
PERSON_DELETE_NOT_FOUND_MESSAGE,
PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE,
} from '@/lib/schemas/personDelete'
import type { PersonDeleteManifest } from '@/utilities/people/personDelete'
type DeletePersonButtonProps = {
personName: string
contactId: number
}
type ManifestState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'ready'; manifest: PersonDeleteManifest }
| { kind: 'error'; message: string }
const mapManifestError = (error: unknown): string => {
const message = error instanceof Error ? error.message : ''
if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) return 'Pessoa não encontrada.'
return 'Não foi possível carregar o que será apagado. Tente de novo.'
}
const mapDeleteError = (error: unknown): string => {
const message = error instanceof Error ? error.message : ''
if (message === PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE) return message
if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) return 'Pessoa não encontrada.'
return 'Não foi possível apagar a pessoa. Tente de novo.'
}
/**
* The C100 destructive action: opens a confirmation that lists — from the
* read-only manifest — everything the transactional cascade will remove, and
* only then offers the destroy. Coordinator/candidate accounts make the whole
* person protected; the ficha is deleted or anonymized (LGPD tombstone)
* depending on public joins.
*/
export const DeletePersonButton = ({ personName, contactId }: DeletePersonButtonProps) => {
const router = useRouter()
const [manifest, setManifest] = useState<ManifestState>({ kind: 'idle' })
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState('')
const triggerRef = useRef<HTMLButtonElement>(null)
const handleOpenChange = async (open: boolean) => {
if (!open) return
setDeleteError('')
setManifest({ kind: 'loading' })
try {
const next = await getPersonDeleteManifestAction({ contactId })
setManifest(
next
? { kind: 'ready', manifest: next }
: {
kind: 'error',
message: mapManifestError(new Error(PERSON_DELETE_NOT_FOUND_MESSAGE)),
},
)
} catch (error) {
setManifest({ kind: 'error', message: mapManifestError(error) })
}
}
const handleDelete = async () => {
if (manifest.kind !== 'ready' || manifest.manifest.hasProtectedAccount) return
setDeleting(true)
setDeleteError('')
try {
const result = await deletePersonAction({ contactId })
toast.success(
result.contactDeleted
? 'Pessoa apagada.'
: result.contactAnonymized
? 'Pessoa apagada. A ficha foi anonimizada porque ainda é referenciada por participações públicas.'
: 'Pessoa apagada.',
)
router.refresh()
} catch (error) {
setDeleteError(mapDeleteError(error))
} finally {
setDeleting(false)
}
}
return (
<AlertDialog onOpenChange={handleOpenChange}>
<AlertDialogTrigger asChild>
<Button
ref={triggerRef}
type="button"
variant="ghost"
size="icon"
className="size-10 text-destructive hover:text-destructive"
aria-label={`Apagar pessoa ${personName}`}
>
<Trash2Icon className="size-4" aria-hidden />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Apagar pessoa</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. Serão removidos todos os vínculos de{' '}
<span className="font-medium text-foreground">{personName}</span> com a campanha.
</AlertDialogDescription>
</AlertDialogHeader>
{manifest.kind === 'loading' ? (
<div className="flex min-h-32 items-center justify-center">
<Spinner aria-label="Carregando o que será apagado" />
</div>
) : null}
{manifest.kind === 'error' ? (
<Alert variant="destructive" aria-live="polite">
<AlertDescription>{manifest.message}</AlertDescription>
</Alert>
) : null}
{manifest.kind === 'ready' ? (
<>
{manifest.manifest.hasProtectedAccount ? (
<Alert variant="destructive" aria-live="polite">
<AlertDescription>
{PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE} A ação está bloqueada.
</AlertDescription>
</Alert>
) : (
<ul className="flex list-disc flex-col gap-1 pl-5 text-sm text-muted-foreground">
{manifest.manifest.leaderships.length > 0 ? (
<li>
Liderança
{manifest.manifest.leaderships.length > 1 ? 's' : ''} (
{manifest.manifest.leaderships
.map((leadership) =>
leadership.municipalityNames.length
? `${leadership.name} · ${leadership.municipalityNames.join(' · ')}`
: leadership.name,
)
.join('; ')}
)
</li>
) : null}
{manifest.manifest.stateDeputies.length > 0 ? (
<li>
Dobradinha{manifest.manifest.stateDeputies.length > 1 ? 's' : ''} (
{manifest.manifest.stateDeputies
.map((deputy) =>
deputy.party ? `${deputy.name} (${deputy.party})` : deputy.name,
)
.join('; ')}
)
</li>
) : null}
{manifest.manifest.pledgeCount > 0 ? (
<li>
{manifest.manifest.pledgeCount}{' '}
{manifest.manifest.pledgeCount === 1 ? 'voto declarado' : 'votos declarados'}
</li>
) : null}
{manifest.manifest.inviteCount > 0 ? (
<li>
{manifest.manifest.inviteCount}{' '}
{manifest.manifest.inviteCount === 1
? 'convite pendente'
: 'convites pendentes'}
</li>
) : null}
{manifest.manifest.supporterCount > 0 ? (
<li>
{manifest.manifest.supporterCount}{' '}
{manifest.manifest.supporterCount === 1
? 'cadastro de apoiador'
: 'cadastros de apoiador'}
</li>
) : null}
{manifest.manifest.municipalityUpdateCount > 0 ? (
<li>
{manifest.manifest.municipalityUpdateCount}{' '}
{manifest.manifest.municipalityUpdateCount === 1
? 'atualização de município'
: 'atualizações de município'}
</li>
) : null}
{manifest.manifest.calendarFeedCount > 0 ? (
<li>
{manifest.manifest.calendarFeedCount}{' '}
{manifest.manifest.calendarFeedCount === 1
? 'link de agenda'
: 'links de agenda'}
</li>
) : null}
{manifest.manifest.accounts.length > 0 ? (
<li>
{manifest.manifest.accounts.length}{' '}
{manifest.manifest.accounts.length === 1
? 'conta de acesso'
: 'contas de acesso'}{' '}
({manifest.manifest.accounts.map((account) => account.name).join('; ')})
</li>
) : null}
<li>
{manifest.manifest.fichaWillBeAnonymized
? 'A ficha de contato será anonimizada (ainda é referenciada por participações públicas).'
: 'Ficha de contato'}
</li>
</ul>
)}
{deleteError ? (
<Alert variant="destructive" aria-live="polite">
<AlertDescription>{deleteError}</AlertDescription>
</Alert>
) : null}
</>
) : null}
<AlertDialogFooter>
<AlertDialogCancel type="button" className="min-h-11" disabled={deleting}>
Cancelar
</AlertDialogCancel>
<Button
type="button"
variant="destructive"
className="min-h-11"
disabled={
manifest.kind !== 'ready' || manifest.manifest.hasProtectedAccount || deleting
}
onClick={handleDelete}
>
{deleting ? <Spinner data-icon="inline-start" /> : null}
Apagar definitivamente
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -0,0 +1,134 @@
'use client'
import { useMemo, useState, type ReactNode } from 'react'
import { SavePeopleFilterControl } from '@/components/campaign/people/SavePeopleFilterControl'
import {
CampaignListOmnibox,
campaignListOmniboxFormClassName,
} from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
import {
buildPeopleFilterHref,
type PeopleFilterOption,
} from '@/utilities/people/peopleListFilters'
import type { PeopleListState } from '@/utilities/people/peopleListUrl'
import {
applyPeopleOmniboxSuggestion,
buildPeopleOmniboxChips,
buildPeopleOmniboxSuggestionSeeds,
clearPeopleOmnibox,
filterPeopleOmniboxSuggestions,
removePeopleOmniboxChip,
type PeopleOmniboxAction,
} from '@/utilities/people/peopleOmnibox'
export const PeopleFilters = ({
state,
municipalityFilterOptions,
trailing,
}: {
state: PeopleListState
municipalityFilterOptions: PeopleFilterOption[]
trailing?: ReactNode
}) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: buildPeopleFilterHref,
})
const [query, setQuery] = useState('')
const municipalityLabelsById = useMemo(() => {
const map = new Map<number, string>()
for (const option of municipalityFilterOptions) {
const id = Number(option.value)
if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label)
}
for (const municipalityId of state.municipalities ?? []) {
if (!map.has(municipalityId)) map.set(municipalityId, `Município #${municipalityId}`)
}
return map
}, [municipalityFilterOptions, state.municipalities])
const chips = useMemo(
() => buildPeopleOmniboxChips({ state, municipalityLabelsById }),
[state, municipalityLabelsById],
)
const suggestionSeeds = useMemo(
() => buildPeopleOmniboxSuggestionSeeds({ municipalityFilterOptions }),
[municipalityFilterOptions],
)
const suggestions = useMemo(
() => filterPeopleOmniboxSuggestions(suggestionSeeds, query),
[suggestionSeeds, query],
)
const runAction = (action: PeopleOmniboxAction) => {
if (action.kind === 'clear') {
setQuery('')
navigate(action.state)
return
}
navigate(action.state)
}
return (
<>
<form
role="search"
className={campaignListOmniboxFormClassName}
onSubmit={(event) => {
event.preventDefault()
}}
>
<CampaignListOmnibox
id="people-omnibox"
label="Filtrar pessoas"
placeholder="Buscar pessoa…"
edgeToEdge
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(applyPeopleOmniboxSuggestion({ state, suggestionId }))
}}
onCommitQuery={(text) => {
runAction(applyPeopleOmniboxSuggestion({ state, suggestionId: `q:${text}` }))
}}
onRemoveChip={(chipId) => {
runAction(removePeopleOmniboxChip({ state, chipId }))
}}
onClearAll={() => {
runAction(clearPeopleOmnibox())
}}
trailing={
<>
{trailing}
<SavePeopleFilterControl
state={state}
municipalityLabelsById={municipalityLabelsById}
variant="trailing"
/>
</>
}
/>
</form>
{/* C100 mobile spec: "Salvar filtro" lives in the app header as an icon
button; the trailing instance is desktop-only (the edge-to-edge
omnibox hides its trailing cluster below `md`). Both instances share
the same store, and only one is visible per breakpoint. */}
<SetCampaignHeaderAction id="save-people-filter">
<SavePeopleFilterControl
state={state}
municipalityLabelsById={municipalityLabelsById}
variant="header"
/>
</SetCampaignHeaderAction>
</>
)
}

View file

@ -0,0 +1,25 @@
'use client'
import { useActivePeopleSavedFilter } from '@/components/campaign/shared/usePeopleSavedFilters'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { campaignPageChromeCatalog } from '@/lib/campaignPageChrome'
/**
* When the current URL matches a saved people filter, the filter name becomes
* the page subtitle (B133 pattern) — the omnibox keeps chips as the editable
* recorte.
*/
export const PeopleListPageChrome = () => {
const activeFilter = useActivePeopleSavedFilter()
if (!activeFilter) return null
return (
<SetCampaignPageChrome
chrome={{
title: campaignPageChromeCatalog.pessoas.title,
subtitle: activeFilter.name,
}}
/>
)
}

View file

@ -0,0 +1,154 @@
'use client'
import { Trash2Icon } from 'lucide-react'
import Link from 'next/link'
import { toast } from 'sonner'
import {
useActivePeopleSavedFilter,
usePeopleSavedFilters,
} from '@/components/campaign/shared/usePeopleSavedFilters'
import {
SidebarMenuAction,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@/components/ui/Sidebar'
import { cn } from '@/lib/utils'
import {
removePeopleSavedFilter,
savePeopleSavedFilter,
type PeopleSavedFilter,
} from '@/utilities/people/peopleSavedFilters'
const PEOPLE_SAVED_FILTERS_LABEL = 'Filtros salvos de Pessoas'
/**
* The saved filters of `/campanha/pessoas` (B18 pattern, 2nd call site), in
* the same two homes as the municipality submenu: the sidebar sub-list under
* the Pessoas nav item, and the mobile "Mais" drawer (overflow). Always
* visible when entries exist; one save repaints both homes.
*/
export const PeopleNavSavedFilters = ({
variant = 'sidebar',
onNavigate,
}: {
variant?: 'sidebar' | 'overflow'
onNavigate: () => void
}) => {
const savedFilters = usePeopleSavedFilters()
const activeFilter = useActivePeopleSavedFilter()
if (!savedFilters.length) return null
/** Where keyboard focus lands after a removal, resolved BEFORE the row the
* button sits on unmounts (same contract as the municipality submenu). */
const resolveSuccessor = (row: HTMLLIElement | null): HTMLElement | null | undefined => {
const siblingRow = row?.nextElementSibling ?? row?.previousElementSibling
if (variant === 'sidebar') {
const menuItem = row?.closest('[data-sidebar="menu-item"]')
return (
siblingRow?.querySelector<HTMLElement>('[data-sidebar="menu-sub-button"]') ??
menuItem?.querySelector<HTMLElement>('a')
)
}
const section = row?.closest('[data-saved-filters="overflow"]')
return (
siblingRow?.querySelector<HTMLElement>('a') ??
section?.nextElementSibling?.querySelector<HTMLElement>('a') ??
section?.previousElementSibling?.querySelector<HTMLElement>('a:last-of-type')
)
}
const handleRemove = (entry: PeopleSavedFilter, row: HTMLLIElement | null) => {
const successor = resolveSuccessor(row)
removePeopleSavedFilter(entry.href)
successor?.focus()
// Undo instead of a confirmation: the entry restores to the same name and
// the same alphabetical slot, so the cheap path is reversal, not a prompt.
toast.success(`Filtro “${entry.name}” apagado.`, {
action: {
label: 'Desfazer',
onClick: () => {
if (savePeopleSavedFilter(entry) !== 'saved') {
toast.error('Não foi possível restaurar o filtro.')
}
},
},
})
}
if (variant === 'overflow') {
return (
<div data-saved-filters="overflow" className="border-t border-border pb-1 pt-3">
<p className="px-3 pb-1 text-xs font-medium text-muted-foreground">
{PEOPLE_SAVED_FILTERS_LABEL}
</p>
<ul className="flex flex-col gap-0.5">
{savedFilters.map((entry) => {
const isActive = entry.href === activeFilter?.href
return (
<li key={entry.href} className="flex items-center gap-1">
<Link
href={entry.href}
onClick={onNavigate}
aria-current={isActive ? 'page' : undefined}
title={entry.name}
className={cn(
'flex min-w-0 flex-1 items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium text-foreground outline-none',
'hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring',
isActive && 'bg-muted text-primary',
)}
>
<span className="truncate">{entry.name}</span>
</Link>
<button
type="button"
onClick={(event) => handleRemove(entry, event.currentTarget.closest('li'))}
aria-label={`Apagar o filtro salvo ${entry.name}`}
className="mr-1 shrink-0 rounded-md p-2.5 text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2Icon aria-hidden className="size-4" />
</button>
</li>
)
})}
</ul>
</div>
)
}
return (
<SidebarMenuSub aria-label={PEOPLE_SAVED_FILTERS_LABEL} className="-mt-0.5 py-0">
{savedFilters.map((entry) => {
const isActive = entry.href === activeFilter?.href
return (
<SidebarMenuSubItem key={entry.href}>
<SidebarMenuSubButton asChild isActive={isActive}>
<Link
href={entry.href}
onClick={onNavigate}
aria-current={isActive ? 'page' : undefined}
title={entry.name}
>
<span>{entry.name}</span>
</Link>
</SidebarMenuSubButton>
<SidebarMenuAction
type="button"
onClick={(event) => handleRemove(entry, event.currentTarget.closest('li'))}
aria-label={`Apagar o filtro salvo ${entry.name}`}
className="top-1 group-focus-within/menu-sub-item:opacity-100 group-hover/menu-sub-item:opacity-100 md:opacity-0"
>
<Trash2Icon aria-hidden="true" />
</SidebarMenuAction>
</SidebarMenuSubItem>
)
})}
</SidebarMenuSub>
)
}

View file

@ -0,0 +1,144 @@
'use client'
import { BookmarkPlusIcon } from 'lucide-react'
import { useId, useState, type FormEvent } from 'react'
import { toast } from 'sonner'
import { usePeopleSavedFilters } from '@/components/campaign/shared/usePeopleSavedFilters'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { cn } from '@/lib/utils'
import {
buildPeopleFilterHref,
formatPeopleActiveFiltersSummary,
} from '@/utilities/people/peopleListFilters'
import type { PeopleListState } from '@/utilities/people/peopleListUrl'
import {
MAX_ENTRIES,
MAX_NAME_LENGTH,
savePeopleSavedFilter,
} from '@/utilities/people/peopleSavedFilters'
type SavePeopleFilterControlProps = {
/**
* The APPLIED state — a search still inside the debounce window is not in the URL yet.
*/
state: PeopleListState
/** Municipality id → name, for the default name and the summary (optional). */
municipalityLabelsById?: ReadonlyMap<number, string>
/**
* `trailing` (desktop, beside the omnibox) or `header` (mobile icon button,
* registered via `SetCampaignHeaderAction` — the C100 mobile spec).
*/
variant?: 'trailing' | 'header'
}
/**
* Names the current recorte and keeps it in the sidebar (B18 pattern, 2nd call
* site). Naming is confirmatory, so this is the explicit-submit exception the
* edit-where-you-see rule already carves out for flows that need a note.
*/
export const SavePeopleFilterControl = ({
state,
municipalityLabelsById,
variant = 'trailing',
}: SavePeopleFilterControlProps) => {
const savedFilters = usePeopleSavedFilters()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const nameId = useId()
const errorId = `${nameId}-error`
const summary = formatPeopleActiveFiltersSummary(state, municipalityLabelsById)
const href = buildPeopleFilterHref(state)
const existing = savedFilters.find((entry) => entry.href === href)
// The bare list has no recorte to name — same gate the clear affordance uses.
if (!summary) return null
const handleOpenChange = (next: boolean) => {
setOpen(next)
if (!next) return
setName(existing?.name ?? summary.slice(0, MAX_NAME_LENGTH))
setErrorMessage('')
}
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!name.trim()) {
setErrorMessage('Dê um nome ao filtro.')
return
}
const result = savePeopleSavedFilter({ href, name })
if (result === 'limit') {
setErrorMessage(
`Você já tem ${MAX_ENTRIES} filtros salvos. Apague um em Pessoas, na navegação, para salvar outro.`,
)
return
}
if (result === 'failed') {
setErrorMessage('Não foi possível salvar neste dispositivo. Tente de novo.')
return
}
setOpen(false)
toast.success(existing ? 'Nome atualizado.' : 'Filtro salvo em Pessoas, na navegação.')
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
className={cn('min-h-11 shrink-0', variant === 'trailing' ? 'md:self-end' : 'md:hidden')}
// SC 2.5.3: an accessible name has to CONTAIN the visible label, or
// "clique em Salvar filtro" matches nothing by voice. The rename case
// may add the target's name because it keeps the visible word first.
aria-label={existing ? `Renomear o filtro salvo ${existing.name}` : undefined}
>
<BookmarkPlusIcon aria-hidden="true" />
<span className={cn(variant === 'header' && 'sr-only')}>
{existing ? 'Renomear' : 'Salvar filtro'}
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" aria-label="Salvar filtro" className="w-80">
{/* Portaled out of the search form above, so this nests no form. */}
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={nameId}>Nome do filtro</FieldLabel>
<Input
id={nameId}
value={name}
onChange={(event) => {
setName(event.target.value)
setErrorMessage('')
}}
maxLength={MAX_NAME_LENGTH}
autoComplete="off"
autoFocus
className="min-h-11"
aria-invalid={errorMessage ? true : undefined}
aria-describedby={errorMessage ? errorId : undefined}
/>
<FieldDescription>{summary}</FieldDescription>
</Field>
{errorMessage ? (
<Alert variant="destructive" id={errorId}>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
) : null}
<Button type="submit" className="min-h-11 w-full">
{existing ? 'Atualizar nome' : 'Salvar'}
</Button>
</form>
</PopoverContent>
</Popover>
)
}

View file

@ -27,6 +27,13 @@ export type CampaignListOmniboxProps = {
isPending?: boolean
/** Domain controls beside the bar (e.g. save bookmark). */
trailing?: ReactNode
/**
* C100: below `md` the trailing cluster (column picker, save bookmark) is
* hidden — B184 already owns the mobile bar chrome (borderless field,
* circle-X clear), and the page registers its own header controls instead.
* `md:` restores the trailing cluster beside the framed desktop field.
*/
edgeToEdge?: boolean
}
/**
@ -76,6 +83,7 @@ export const CampaignListOmnibox = ({
onClearAll,
isPending = false,
trailing,
edgeToEdge = false,
}: CampaignListOmniboxProps) => {
const reactId = useId()
const id = idProp ?? `campaign-list-omnibox-${reactId}`
@ -357,7 +365,12 @@ export const CampaignListOmnibox = ({
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 md:pt-7">
<div
className={cn(
'flex shrink-0 flex-wrap items-center gap-2 md:pt-7',
edgeToEdge && 'hidden md:flex',
)}
>
{trailing}
{onClearAll && hasChips ? (
<Button

View file

@ -0,0 +1,43 @@
'use client'
import { usePathname, useSearchParams } from 'next/navigation'
import { useMemo, useSyncExternalStore } from 'react'
import { isSameListHref } from '@/lib/listQueryMatch'
import {
listPeopleSavedFilters,
subscribePeopleSavedFilters,
type PeopleSavedFilter,
} from '@/utilities/people/peopleSavedFilters'
/** A page is a position inside a recorte, not part of it (B18 pattern). */
const PEOPLE_SAVED_FILTER_IGNORED_PARAMS = ['page'] as const
/**
* Stable reference for the render that has no `localStorage` to read. Returning
* a fresh `[]` here is the classic `useSyncExternalStore` render loop.
*/
const NO_SAVED_FILTERS: readonly PeopleSavedFilter[] = []
/**
* The filter bar writes this store and the sidebar reads it, from trees that
* never meet — so both subscribe rather than lift state, and one write repaints
* both (same contract as `useMunicipalitySavedFilters`).
*/
export const usePeopleSavedFilters = (): readonly PeopleSavedFilter[] =>
useSyncExternalStore(subscribePeopleSavedFilters, listPeopleSavedFilters, () => NO_SAVED_FILTERS)
/** The saved filter whose href matches the current list URL, if any. */
export const useActivePeopleSavedFilter = (): PeopleSavedFilter | undefined => {
const savedFilters = usePeopleSavedFilters()
const pathname = usePathname()
const searchParams = useSearchParams()
return useMemo(() => {
const query = searchParams.toString()
const currentHref = query ? `${pathname}?${query}` : pathname
return savedFilters.find((entry) =>
isSameListHref(currentHref, entry.href, PEOPLE_SAVED_FILTER_IGNORED_PARAMS),
)
}, [pathname, searchParams, savedFilters])
}

View file

@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation'
import { useState, type FormEvent } from 'react'
import { logoutCampaign } from '@/app/(campaign)/campanha/actions/auth'
import { PeopleNavSavedFilters } from '@/components/campaign/people/PeopleNavSavedFilters'
import { MunicipalityNavSavedFilters } from '@/components/campaign/shell/MunicipalityNavSavedFilters'
import {
getCampaignBottomNav,
@ -28,6 +29,7 @@ import { cn } from '@/lib/utils'
import { clearCampaignPwaCaches } from '@/utilities/campaignPwaClient'
import { type CampaignUserShellView } from '@/utilities/campaignUserProfile'
import { clearMunicipalitySavedFilters } from '@/utilities/municipality/municipalitySavedFilters'
import { clearPeopleSavedFilters } from '@/utilities/people/peopleSavedFilters'
import { clearRecentVisits } from '@/utilities/recentVisits'
export const CampaignBottomNav = ({ user }: { user: CampaignUserShellView }) => {
@ -44,6 +46,7 @@ export const CampaignBottomNav = ({ user }: { user: CampaignUserShellView }) =>
clearRecentVisits()
clearLastActedMunicipality()
clearMunicipalitySavedFilters()
clearPeopleSavedFilters()
await clearCampaignPwaCaches()
await logoutCampaign()
}
@ -144,6 +147,7 @@ export const CampaignBottomNav = ({ user }: { user: CampaignUserShellView }) =>
variant="overflow"
onNavigate={() => setOverflowOpen(false)}
/>
<PeopleNavSavedFilters variant="overflow" onNavigate={() => setOverflowOpen(false)} />
</div>
<DrawerFooter>

View file

@ -7,6 +7,7 @@ import { useState, type FormEvent, type ReactNode } from 'react'
import { LogOutIcon } from 'lucide-react'
import { logoutCampaign } from '@/app/(campaign)/campanha/actions/auth'
import { PeopleNavSavedFilters } from '@/components/campaign/people/PeopleNavSavedFilters'
import { CampaignUserAvatar } from '@/components/campaign/shared/CampaignUserAvatar'
import { MunicipalityNavSavedFilters } from '@/components/campaign/shell/MunicipalityNavSavedFilters'
import {
@ -14,6 +15,7 @@ import {
getCampaignSecondaryNav,
isCampaignNavActive,
MUNICIPALITY_NAV_HREF,
PEOPLE_NAV_HREF,
type CampaignNavItem,
} from '@/components/campaign/shell/nav'
import { Button } from '@/components/ui/button'
@ -35,6 +37,7 @@ import { clearCampaignPwaCaches } from '@/utilities/campaignPwaClient'
import type { CampaignUserShellView } from '@/utilities/campaignUserProfile'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { clearMunicipalitySavedFilters } from '@/utilities/municipality/municipalitySavedFilters'
import { clearPeopleSavedFilters } from '@/utilities/people/peopleSavedFilters'
import { clearRecentVisits } from '@/utilities/recentVisits'
export type CampaignSidebarUser = CampaignUserShellView
@ -84,6 +87,7 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
clearRecentVisits()
clearLastActedMunicipality()
clearMunicipalitySavedFilters()
clearPeopleSavedFilters()
await clearCampaignPwaCaches()
// logoutCampaign redirects — no need to reset the pending flag on success.
await logoutCampaign()
@ -105,6 +109,9 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
{item.href === MUNICIPALITY_NAV_HREF ? (
<MunicipalityNavSavedFilters onNavigate={closeMobileSidebar} />
) : null}
{item.href === PEOPLE_NAV_HREF ? (
<PeopleNavSavedFilters onNavigate={closeMobileSidebar} />
) : null}
</CampaignSidebarLink>
))}
</SidebarMenu>

View file

@ -2,6 +2,7 @@ import {
BellIcon,
BookOpenIcon,
CalendarDaysIcon,
ContactIcon,
EllipsisVerticalIcon,
HandshakeIcon,
HomeIcon,
@ -38,6 +39,12 @@ export type CampaignNavItem = {
*/
export const MUNICIPALITY_NAV_HREF = '/campanha/municipios'
/**
* Named because the sidebar has to recognize this item to hang the C100 saved
* filters under it — same contract as `MUNICIPALITY_NAV_HREF`.
*/
export const PEOPLE_NAV_HREF = '/campanha/pessoas'
const staffNav: CampaignNavItem[] = [
{ title: 'Início', href: '/campanha', icon: HomeIcon },
{ title: 'Quadro', href: '/campanha/quadro', icon: LayoutDashboardIcon },
@ -46,6 +53,7 @@ const staffNav: CampaignNavItem[] = [
{ title: 'Lideranças', href: '/campanha/liderancas', icon: HandshakeIcon },
{ title: 'Organizações', href: '/campanha/organizacoes', icon: LandmarkIcon },
{ title: 'Dobradinhas', href: '/campanha/dobradinhas', icon: Users2Icon },
{ title: 'Pessoas', href: PEOPLE_NAV_HREF, icon: ContactIcon },
{ title: 'Agenda', href: CAMPAIGN_AGENDA_HOME, icon: CalendarDaysIcon },
{ title: 'Demandas', href: '/campanha/demandas', icon: InboxIcon },
{ title: 'Apoiadores', href: '/campanha/apoiadores', icon: UsersIcon },

View file

@ -25,6 +25,7 @@ const CAMPAIGN_LIST_IDS = [
'demandas',
'apoiadores',
'territorios',
'pessoas',
] as const
export type CampaignListId = (typeof CAMPAIGN_LIST_IDS)[number]

View file

@ -45,6 +45,9 @@ export const campaignPageChromeCatalog = {
dobradinhas: {
title: 'Dobradinhas',
},
pessoas: {
title: 'Pessoas',
},
dobradinhasNova: {
title: 'Nova dobradinha',
},
@ -178,6 +181,10 @@ const pathRules: PathRule[] = [
match: (pathname) => pathname === '/campanha/agenda',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.agenda),
},
{
match: (pathname) => pathname === '/campanha/pessoas',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.pessoas),
},
{
match: (pathname) => pathname === '/campanha/atividades/nova',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atividadesNova),

View file

@ -0,0 +1,17 @@
import { z } from 'zod'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
export const personDeleteInputSchema = z.object({
contactId: positiveRelationshipId,
})
/**
* Refusal messages shared verbatim between the server action (thrown) and the
* client dialog (rendered) — the `*_MESSAGE` constant convention. Client-safe.
*/
export const PERSON_DELETE_NOT_FOUND_MESSAGE = 'Pessoa não encontrada.'
export const PERSON_DELETE_FORBIDDEN_MESSAGE =
'Somente a coordenação e o candidato podem apagar pessoas.'
export const PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE =
'Esta pessoa tem conta de coordenação ou candidato e não pode ser apagada.'

View file

@ -0,0 +1,164 @@
/**
* Device-local named bookmarks of a list URL (B18 pattern, 2nd call site: C100).
*
* The municipality store was the first call site; the semantics are subtle
* enough that a second copy would rot independently (rename-at-cap, the
* cross-island change event, the snapshot cache), so the storage layer is this
* one factory and each list keeps a thin domain module over it. The FD2 veto
* is about a GENERIC saved-filters SYSTEM (server-sync, config-driven) — a
* shared storage primitive with two concrete stores is the edit-the-owner
* move, not that system.
*
* The canonical href IS the entry's identity: one shortcut per recorte, and
* re-saving a slice that is already stored renames it instead of growing a
* second row that navigates to the same place.
*/
export const SAVED_FILTER_MAX_ENTRIES = 12
export const SAVED_FILTER_MAX_NAME_LENGTH = 60
export type CampaignSavedFilter = {
/** Canonical list href (page 1) — also the entry's identity. */
href: string
name: string
}
export type SaveCampaignSavedFilterResult = 'saved' | 'limit' | 'failed'
export type CampaignSavedFilterStoreOptions = {
storageKey: string
/** Rejects entries from another list (the href prefix is the guard). */
isHrefValid: (href: string) => boolean
}
export type CampaignSavedFilterStore = {
list: () => readonly CampaignSavedFilter[]
save: (entry: CampaignSavedFilter) => SaveCampaignSavedFilterResult
remove: (href: string) => void
clear: () => void
subscribe: (onChange: () => void) => () => void
}
export const createSavedFilterStore = ({
storageKey,
isHrefValid,
}: CampaignSavedFilterStoreOptions): CampaignSavedFilterStore => {
/**
* Two islands share a store — the filter bar writes it, the sidebar reads it
* — and they never share a React tree, so a write has to announce itself.
* The native `storage` event covers other tabs and deliberately does not
* fire in the tab that wrote, which is exactly the half this event adds.
*/
const changeEvent = `${storageKey}-change`
const empty: readonly CampaignSavedFilter[] = Object.freeze([])
/**
* `useSyncExternalStore` compares snapshots by reference, so parsing storage
* on every `getSnapshot` call would render forever. The cache is dropped by
* every write and by a `storage` event from another tab.
*/
let snapshot: readonly CampaignSavedFilter[] | null = null
const isEntry = (value: unknown): value is CampaignSavedFilter => {
if (!value || typeof value !== 'object') return false
const entry = value as Record<string, unknown>
return (
typeof entry.href === 'string' &&
isHrefValid(entry.href) &&
typeof entry.name === 'string' &&
entry.name.trim().length > 0
)
}
/**
* Alphabetical, not by recency: a named shortcut that reorders itself under
* the cursor every time it is re-saved is a nav bug. Recency is Visitados'
* job.
*/
const byName = (left: CampaignSavedFilter, right: CampaignSavedFilter): number =>
left.name.localeCompare(right.name, 'pt-BR')
const read = (): readonly CampaignSavedFilter[] => {
try {
const raw = localStorage.getItem(storageKey)
if (!raw) return empty
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return empty
const seen = new Set<string>()
const entries = parsed.filter(isEntry).filter((entry) => {
if (seen.has(entry.href)) return false
seen.add(entry.href)
return true
})
return entries.length ? entries.slice(0, SAVED_FILTER_MAX_ENTRIES).sort(byName) : empty
} catch {
return empty
}
}
const list = (): readonly CampaignSavedFilter[] => {
if (typeof window === 'undefined') return empty
snapshot ??= read()
return snapshot
}
const write = (entries: readonly CampaignSavedFilter[]): boolean => {
let persisted = true
try {
if (entries.length) localStorage.setItem(storageKey, JSON.stringify(entries))
else localStorage.removeItem(storageKey)
} catch {
// Quota or private mode. Readers are told to re-read either way, so the
// submenu shows what actually persisted rather than a save that did not.
persisted = false
}
snapshot = null
window.dispatchEvent(new Event(changeEvent))
return persisted
}
const save = (entry: CampaignSavedFilter): SaveCampaignSavedFilterResult => {
if (typeof window === 'undefined') return 'failed'
const name = entry.name.trim().slice(0, SAVED_FILTER_MAX_NAME_LENGTH)
if (!name) return 'failed'
const others = list().filter((item) => item.href !== entry.href)
// Renaming at the cap has to keep working, which is why the ceiling is
// checked against the OTHER entries and not against the stored length.
if (others.length >= SAVED_FILTER_MAX_ENTRIES) return 'limit'
return write([...others, { href: entry.href, name }].sort(byName)) ? 'saved' : 'failed'
}
const remove = (href: string): void => {
if (typeof window === 'undefined') return
const existing = list()
const next = existing.filter((item) => item.href !== href)
if (next.length === existing.length) return
write(next)
}
const clear = (): void => {
if (typeof window === 'undefined') return
write([])
}
const subscribe = (onChange: () => void): (() => void) => {
if (typeof window === 'undefined') return () => {}
const handleChange = (event: Event) => {
// `storage` fires for every key in the origin (recent visits, biometrics
// flags); a null key means the whole store was cleared.
if (event instanceof StorageEvent && event.key !== null && event.key !== storageKey) return
snapshot = null
onChange()
}
window.addEventListener(changeEvent, handleChange)
window.addEventListener('storage', handleChange)
return () => {
window.removeEventListener(changeEvent, handleChange)
window.removeEventListener('storage', handleChange)
}
}
return { list, save, remove, clear, subscribe }
}

View file

@ -1,128 +1,40 @@
/**
* Named bookmarks of the municipality list URL (B18).
*
* Device-local on purpose: a nav shortcut never has to reach the RSC render, so
* this is `localStorage` — the same call `recentVisits.ts` made, and unlike
* B17's column cookie, which the server reads to filter columns.
*
* The canonical href IS the identity. One shortcut per recorte, so re-saving a
* slice that is already stored renames it instead of growing a second submenu
* row that navigates to the same place.
* Named bookmarks of the municipality list URL (B18). The storage layer moved
* to the shared `createSavedFilterStore` factory (2nd call site: C100 people);
* this module keeps the B18 public API byte-for-byte so the sidebar, the save
* control and the unit suite keep working unchanged.
*/
import {
createSavedFilterStore,
SAVED_FILTER_MAX_ENTRIES,
SAVED_FILTER_MAX_NAME_LENGTH,
type CampaignSavedFilter,
type SaveCampaignSavedFilterResult,
} from '@/utilities/campaignSavedFilterStore'
export const STORAGE_KEY = 'teqo:campaign:municipality-saved-filters'
/**
* A named bookmark is not a history entry: at the cap the save is refused with
* a reason instead of silently evicting something the user deliberately named
* (which is what `recentVisits`'s `slice` does, correctly, for dwell history).
*/
export const MAX_ENTRIES = 12
export type MunicipalitySavedFilter = CampaignSavedFilter
export const MAX_NAME_LENGTH = 60
export type SaveMunicipalitySavedFilterResult = SaveCampaignSavedFilterResult
/**
* Two islands share this store — the filter bar writes it, the sidebar reads it
* — and they never share a React tree, so a write has to announce itself. The
* native `storage` event covers other tabs and deliberately does not fire in the
* tab that wrote, which is exactly the half this event adds.
*/
const CHANGE_EVENT = 'teqo:campaign:municipality-saved-filters-change'
export { SAVED_FILTER_MAX_ENTRIES as MAX_ENTRIES, SAVED_FILTER_MAX_NAME_LENGTH as MAX_NAME_LENGTH }
export type MunicipalitySavedFilter = {
/** Canonical list href (page 1, no `compare`) — also the entry's identity. */
href: string
name: string
}
const store = createSavedFilterStore({
storageKey: STORAGE_KEY,
isHrefValid: (href) => href.startsWith('/campanha/municipios'),
})
export type SaveMunicipalitySavedFilterResult = 'saved' | 'limit' | 'failed'
export const listMunicipalitySavedFilters = store.list
const EMPTY: readonly MunicipalitySavedFilter[] = Object.freeze([])
/**
* `useSyncExternalStore` compares snapshots by reference, so parsing storage on
* every `getSnapshot` call would render forever. The cache is dropped by every
* write and by a `storage` event from another tab.
*/
let snapshot: readonly MunicipalitySavedFilter[] | null = null
const isMunicipalitySavedFilter = (value: unknown): value is MunicipalitySavedFilter => {
if (!value || typeof value !== 'object') return false
const entry = value as Record<string, unknown>
return (
typeof entry.href === 'string' &&
entry.href.startsWith('/campanha/municipios') &&
typeof entry.name === 'string' &&
entry.name.trim().length > 0
)
}
/**
* Alphabetical, not by recency: a named shortcut that reorders itself under the
* cursor every time it is re-saved is a nav bug. Recency is Visitados' job.
*/
const byName = (left: MunicipalitySavedFilter, right: MunicipalitySavedFilter): number =>
left.name.localeCompare(right.name, 'pt-BR')
const read = (): readonly MunicipalitySavedFilter[] => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return EMPTY
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return EMPTY
const seen = new Set<string>()
const entries = parsed.filter(isMunicipalitySavedFilter).filter((entry) => {
if (seen.has(entry.href)) return false
seen.add(entry.href)
return true
})
return entries.length ? entries.slice(0, MAX_ENTRIES).sort(byName) : EMPTY
} catch {
return EMPTY
}
}
export const listMunicipalitySavedFilters = (): readonly MunicipalitySavedFilter[] => {
if (typeof window === 'undefined') return EMPTY
snapshot ??= read()
return snapshot
}
const write = (entries: readonly MunicipalitySavedFilter[]): boolean => {
let persisted = true
try {
if (entries.length) localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
else localStorage.removeItem(STORAGE_KEY)
} catch {
// Quota or private mode. Readers are told to re-read either way, so the
// submenu shows what actually persisted rather than a save that did not.
persisted = false
}
snapshot = null
window.dispatchEvent(new Event(CHANGE_EVENT))
return persisted
}
export const saveMunicipalitySavedFilter = (
export const saveMunicipalitySavedFilter: (
entry: MunicipalitySavedFilter,
): SaveMunicipalitySavedFilterResult => {
if (typeof window === 'undefined') return 'failed'
const name = entry.name.trim().slice(0, MAX_NAME_LENGTH)
if (!name) return 'failed'
const others = listMunicipalitySavedFilters().filter((item) => item.href !== entry.href)
// Renaming at the cap has to keep working, which is why the ceiling is checked
// against the OTHER entries and not against the stored length.
if (others.length >= MAX_ENTRIES) return 'limit'
return write([...others, { href: entry.href, name }].sort(byName)) ? 'saved' : 'failed'
}
) => SaveMunicipalitySavedFilterResult = store.save
export const removeMunicipalitySavedFilter = (href: string): void => {
if (typeof window === 'undefined') return
const existing = listMunicipalitySavedFilters()
const next = existing.filter((item) => item.href !== href)
if (next.length === existing.length) return
write(next)
}
export const removeMunicipalitySavedFilter = store.remove
export const subscribeMunicipalitySavedFilters = store.subscribe
/** Legacy B18 disclosure key — removed by B124; cleared on logout. */
const LEGACY_OPEN_STORAGE_KEY = 'teqo:campaign:municipality-saved-filters-open'
@ -134,22 +46,5 @@ export const clearMunicipalitySavedFilters = (): void => {
} catch {
// A stale preference is a nicety.
}
write([])
}
export const subscribeMunicipalitySavedFilters = (onChange: () => void): (() => void) => {
if (typeof window === 'undefined') return () => {}
const handleChange = (event: Event) => {
// `storage` fires for every key in the origin (recent visits, biometrics
// flags); a null key means the whole store was cleared.
if (event instanceof StorageEvent && event.key !== null && event.key !== STORAGE_KEY) return
snapshot = null
onChange()
}
window.addEventListener(CHANGE_EVENT, handleChange)
window.addEventListener('storage', handleChange)
return () => {
window.removeEventListener(CHANGE_EVENT, handleChange)
window.removeEventListener('storage', handleChange)
}
store.clear()
}

View file

@ -0,0 +1,474 @@
import 'server-only'
import type { Payload } from 'payload'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { isSupportStatus, type SupportStatus } from '@/lib/schemas/leadership'
import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types'
import { municipalityIdsByAdvisorIds } from '@/utilities/advisorData'
import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess'
import { loadCampaignUserNamesByIds } from '@/utilities/loadNamesByIds'
import {
buildPeopleDeputySourceWhere,
buildPeopleLeadershipSourceWhere,
buildPeopleStaffSourceWhere,
peoplePageSize,
type PeopleListState,
} from '@/utilities/people/peopleListUrl'
import { municipalityIdsByStateDeputyIds } from '@/utilities/stateDeputyData'
/**
* The unified people list (C100): three domain sources — leadership, dobradinha
* and staff accounts — each read with the access of its own collection, then
* merged in memory by `Contact` id so one physical person is ONE row whose
* capacities fill columns.
*
* Access contract (impl plan D2/D3): the actor's scope is enforced by the
* merge, never by widening a domain where. An advisor sees only rows with at
* least one capacity municipality in his carteira; unrestricted actors see
* every person with any role. The staff source reads with a justified
* `overrideAccess` (the `contact` field is identity-gated and would be cut
* from the depth-1 populate otherwise) — same precedent as
* `loadAdvisorListPageData` and `canReadCampaignUserPhone`'s same-municipality
* rule: the surface never exposes a row the merge scope did not authorize.
*/
type PeopleStaffCapacity = {
id: number
name: string
role: CampaignUser['role']
municipalityIDs: number[]
}
export type PeopleRowViewModel = {
contactID: number
name: string
phone: string | null
email: string | null
city: string | null
/** `stateDeputy.party` — the one party source (at most one dobradinha per ficha). */
party: string | null
leadershipID: number | null
leadershipMunicipalityIDs: number[]
supportStatus: SupportStatus | null
hasAppAccess: boolean
deputyID: number | null
deputyMunicipalityIDs: number[]
staff: PeopleStaffCapacity[]
/** Union of the staff accounts' carteiras — the Assessora column. */
assessoraMunicipalityIDs: number[]
/** Union of every capacity's municipalities — scope and the Município facet. */
capacityMunicipalityIDs: number[]
/** Union of leadership + dobradinha advisors, names resolved (Assessorado). */
assessoradoNames: string[]
}
/** Merge intermediate: the ids the loader resolves into `assessoradoNames`. */
export type MergedPerson = Omit<PeopleRowViewModel, 'assessoradoNames'> & {
leadershipAdvisorIDs: number[]
deputyAdvisorIDs: number[]
}
type PeopleLeadershipSource = {
id: number
contactID: number
name: string
phone: string | null
email: string | null
city: string | null
municipalityIDs: number[]
supportStatus: SupportStatus | null
hasAppAccess: boolean
advisorIDs: number[]
}
type PeopleDeputySource = {
id: number
contactID: number
name: string
phone: string | null
email: string | null
city: string | null
party: string | null
municipalityIDs: number[]
advisorIDs: number[]
}
type PeopleStaffSource = {
id: number
contactID: number
name: string
phone: string | null
email: string | null
city: string | null
role: CampaignUser['role']
municipalityIDs: number[]
}
export type PeopleMergeSources = {
leaderships: readonly PeopleLeadershipSource[]
deputies: readonly PeopleDeputySource[]
staff: readonly PeopleStaffSource[]
}
const sortedDistinct = (values: Iterable<number>): number[] =>
[...new Set(values)].sort((left, right) => left - right)
/**
* One row per `contactID`. The ficha fields come from the first source that
* carries them (leadership, then dobradinha, then staff — same ficha, so the
* data cannot disagree); capacities accumulate across sources. Staff with an
* EMPTY carteira still count as a capacity: being staff is the papel, the
* carteira only fills the Assessora column (gate 2026-08-09).
*/
export const mergePeopleSources = (sources: PeopleMergeSources): MergedPerson[] => {
const byContact = new Map<number, MergedPerson>()
const personFor = (contactID: number): MergedPerson => {
const existing = byContact.get(contactID)
if (existing) return existing
const person: MergedPerson = {
contactID,
name: 'Contato',
phone: null,
email: null,
city: null,
party: null,
leadershipID: null,
leadershipMunicipalityIDs: [],
supportStatus: null,
hasAppAccess: false,
leadershipAdvisorIDs: [],
deputyID: null,
deputyMunicipalityIDs: [],
deputyAdvisorIDs: [],
staff: [],
assessoraMunicipalityIDs: [],
capacityMunicipalityIDs: [],
}
byContact.set(contactID, person)
return person
}
for (const leadership of sources.leaderships) {
const person = personFor(leadership.contactID)
person.name = leadership.name
person.phone = leadership.phone
person.email = leadership.email
person.city = leadership.city
person.leadershipID = leadership.id
person.leadershipMunicipalityIDs = [...leadership.municipalityIDs]
person.supportStatus = leadership.supportStatus
person.hasAppAccess = leadership.hasAppAccess
person.leadershipAdvisorIDs = [...leadership.advisorIDs]
}
for (const deputy of sources.deputies) {
const person = personFor(deputy.contactID)
if (person.leadershipID === null) {
person.name = deputy.name
person.phone = deputy.phone
person.email = deputy.email
person.city = deputy.city
}
person.party = deputy.party
person.deputyID = deputy.id
person.deputyMunicipalityIDs = [...deputy.municipalityIDs]
person.deputyAdvisorIDs = [...deputy.advisorIDs]
}
for (const staff of sources.staff) {
const person = personFor(staff.contactID)
if (person.leadershipID === null && person.deputyID === null) {
person.name = staff.name
person.phone = staff.phone
person.email = staff.email
person.city = staff.city
}
person.staff.push({
id: staff.id,
name: staff.name,
role: staff.role,
municipalityIDs: [...staff.municipalityIDs],
})
}
for (const person of byContact.values()) {
const assessora = sortedDistinct(person.staff.flatMap((account) => account.municipalityIDs))
person.assessoraMunicipalityIDs = assessora
person.capacityMunicipalityIDs = sortedDistinct([
...person.leadershipMunicipalityIDs,
...person.deputyMunicipalityIDs,
...assessora,
])
}
return [...byContact.values()]
}
/**
* Advisor viewer scope: a row stays iff at least one capacity municipality is
* in the actor's carteira. `null` = unrestricted (no filter). Staff with an
* empty carteira have no capacity municipality and are therefore only visible
* to unrestricted actors — the consistent consequence of the inclusion rule.
*/
export const scopePeopleRows = (
rows: readonly MergedPerson[],
accessibleMunicipalityIds: ReadonlySet<number> | null,
): MergedPerson[] => {
if (accessibleMunicipalityIds === null) return [...rows]
return rows.filter((row) =>
row.capacityMunicipalityIDs.some((id) => accessibleMunicipalityIds.has(id)),
)
}
/**
* In-memory list filters (C100): capacities OR within the facet (same as every
* other list facet), municipalities intersect any capacity, statuses only
* match leadership rows. `q` already ran at the source level.
*/
export const filterPeopleRows = (
rows: readonly MergedPerson[],
state: PeopleListState,
): MergedPerson[] =>
rows.filter((row) => {
if (state.capacities?.length) {
const wanted = new Set(state.capacities)
const matches =
(wanted.has('assessora') && row.assessoraMunicipalityIDs.length > 0) ||
(wanted.has('lideranca') && row.leadershipID !== null) ||
(wanted.has('dobradinha') && row.deputyID !== null)
if (!matches) return false
}
if (state.municipalities?.length) {
const selected = new Set(state.municipalities)
if (!row.capacityMunicipalityIDs.some((id) => selected.has(id))) return false
}
if (state.statuses?.length) {
if (row.supportStatus === null || !state.statuses.includes(row.supportStatus)) return false
}
return true
})
export type PeopleListFilterFacets = {
/** Municipality ids present across the scoped rows (selected values unioned in). */
municipalityIDs: number[]
/** Leadership statuses present across the scoped rows (selected values unioned in). */
statuses: SupportStatus[]
}
export const peopleFilterFacetsFromRows = (
rows: readonly MergedPerson[],
state: PeopleListState,
): PeopleListFilterFacets => {
const municipalities = new Set<number>(state.municipalities ?? [])
const statuses = new Set<SupportStatus>(state.statuses ?? [])
for (const row of rows) {
for (const id of row.capacityMunicipalityIDs) municipalities.add(id)
if (row.supportStatus !== null) statuses.add(row.supportStatus)
}
return {
municipalityIDs: [...municipalities].sort((left, right) => left - right),
statuses: leadershipStatusesInOrder(statuses),
}
}
const leadershipStatusesInOrder = (statuses: ReadonlySet<SupportStatus>): SupportStatus[] =>
(['engajado', 'a_abordar', 'em_disputa', 'negativo'] as const).filter((status) =>
statuses.has(status),
)
const EMPTY_FACETS: PeopleListFilterFacets = { municipalityIDs: [], statuses: [] }
type ContactSummary = {
id: number
name: string
phone: string | null
email: string | null
city: string | null
}
const contactSummary = (contact: Contact | number | null | undefined): ContactSummary => {
if (contact !== null && typeof contact === 'object') {
return {
id: contact.id,
name: contact.name ?? 'Contato',
phone: contact.phone ?? null,
email: contact.email ?? null,
city: contact.city ?? null,
}
}
return { id: Number(contact), name: 'Contato', phone: null, email: null, city: null }
}
/**
* The page data for `/campanha/pessoas`. `q` runs at the source level; the
* capacity/municipality/status filters, the advisor scope and the facets run
* over the merged rows. Advisor names for the Assessorado column are resolved
* for the visible page only.
*/
export const loadPeopleListPageData = async (
payload: Payload,
user: CampaignUser,
state: PeopleListState,
): Promise<{
rows: PeopleRowViewModel[]
totalDocs: number
totalPages: number
filterFacets: PeopleListFilterFacets
}> => {
if (!isCampaignStaff(user)) {
return { rows: [], totalDocs: 0, totalPages: 0, filterFacets: EMPTY_FACETS }
}
const [leadershipResult, deputyResult, staffResult] = await Promise.all([
payload.find({
collection: 'leadership',
where: buildPeopleLeadershipSourceWhere(state),
depth: 1,
limit: 0,
pagination: false,
select: {
contact: true,
municipalities: true,
advisors: true,
supportStatus: true,
user: true,
},
user,
overrideAccess: false,
}),
payload.find({
collection: 'stateDeputy',
where: buildPeopleDeputySourceWhere(state),
depth: 1,
limit: 0,
pagination: false,
select: { contact: true, slug: true, party: true, advisors: true },
user,
overrideAccess: false,
}),
payload.find({
collection: 'campaignUser',
where: buildPeopleStaffSourceWhere(state),
depth: 1,
limit: 0,
pagination: false,
select: { name: true, role: true, contact: true },
// Intentional admin bypass (impl plan D2): the `contact` field is
// identity-gated (`canReadCampaignUserIdentity`) and the actor scope is
// enforced by the merge below — same rationale as `loadAdvisorListPageData`.
overrideAccess: true,
}),
])
const [deputyMunicipalityIdsByDeputy, staffMunicipalityIdsByStaff] = await Promise.all([
municipalityIdsByStateDeputyIds(
payload,
deputyResult.docs.map((doc) => doc.id),
),
municipalityIdsByAdvisorIds(
payload,
staffResult.docs.map((doc) => doc.id),
),
])
const leaderships: PeopleLeadershipSource[] = (leadershipResult.docs as Leadership[]).map(
(doc) => {
const contact = contactSummary(doc.contact)
return {
id: doc.id,
contactID: contact.id,
name: contact.name,
phone: contact.phone,
email: contact.email,
city: contact.city,
municipalityIDs: uniqueRelationshipIds(doc.municipalities),
supportStatus: isSupportStatus(doc.supportStatus) ? doc.supportStatus : null,
hasAppAccess: relationshipId(doc.user) !== null,
advisorIDs: uniqueRelationshipIds(doc.advisors),
}
},
)
const deputies: PeopleDeputySource[] = (deputyResult.docs as StateDeputy[]).map((doc) => {
const contact = contactSummary(doc.contact)
return {
id: doc.id,
contactID: contact.id,
name: contact.name,
phone: contact.phone,
email: contact.email,
city: contact.city,
party: doc.party ?? null,
municipalityIDs: deputyMunicipalityIdsByDeputy.get(doc.id) ?? [],
advisorIDs: uniqueRelationshipIds(doc.advisors),
}
})
const staff: PeopleStaffSource[] = (staffResult.docs as CampaignUser[]).map((doc) => {
const contact = contactSummary(doc.contact)
return {
id: doc.id,
contactID: contact.id,
name: contact.name,
phone: contact.phone,
email: contact.email,
city: contact.city,
role: doc.role,
municipalityIDs: staffMunicipalityIdsByStaff.get(doc.id) ?? [],
}
})
const merged = mergePeopleSources({ leaderships, deputies, staff })
const accessibleMunicipalityIds =
user.role === 'advisor' ? new Set(await getAdvisorMunicipalityIds(payload, user.id)) : null
const scoped = scopePeopleRows(merged, accessibleMunicipalityIds)
const filterFacets = peopleFilterFacetsFromRows(scoped, state)
const filtered = filterPeopleRows(scoped, state)
const sorted = [...filtered].sort(
(left, right) =>
left.name.localeCompare(right.name, 'pt-BR') || left.contactID - right.contactID,
)
const totalDocs = sorted.length
const totalPages = Math.ceil(totalDocs / peoplePageSize)
const pageRows = sorted.slice((state.page - 1) * peoplePageSize, state.page * peoplePageSize)
const advisorIDs = new Set<number>()
for (const row of pageRows) {
for (const id of row.leadershipAdvisorIDs) advisorIDs.add(id)
for (const id of row.deputyAdvisorIDs) advisorIDs.add(id)
}
const advisorNames = await loadCampaignUserNamesByIds(payload, [...advisorIDs])
const rows: PeopleRowViewModel[] = pageRows.map((person) => {
const assessoradoNames = [
...new Set(
[...person.leadershipAdvisorIDs, ...person.deputyAdvisorIDs]
.map((id) => advisorNames.get(id))
.filter((name): name is string => name !== undefined),
),
].sort((left, right) => left.localeCompare(right, 'pt-BR'))
return {
contactID: person.contactID,
name: person.name,
phone: person.phone,
email: person.email,
city: person.city,
party: person.party,
leadershipID: person.leadershipID,
leadershipMunicipalityIDs: person.leadershipMunicipalityIDs,
supportStatus: person.supportStatus,
hasAppAccess: person.hasAppAccess,
deputyID: person.deputyID,
deputyMunicipalityIDs: person.deputyMunicipalityIDs,
staff: person.staff,
assessoraMunicipalityIDs: person.assessoraMunicipalityIDs,
capacityMunicipalityIDs: person.capacityMunicipalityIDs,
assessoradoNames,
}
})
return { rows, totalDocs, totalPages, filterFacets }
}

View file

@ -0,0 +1,15 @@
/**
* People list display labels and the capacity enum (C100). Client-safe: a
* plain string table, no Payload/DB — same contract as `leadershipLabels`.
*/
/** The three capacity facets — derived from the columns, OR within the facet. */
export const PEOPLE_CAPACITIES = ['assessora', 'lideranca', 'dobradinha'] as const
export type PeopleCapacity = (typeof PEOPLE_CAPACITIES)[number]
export const peopleCapacityLabels: Record<PeopleCapacity, string> = {
assessora: 'É assessora',
lideranca: 'É liderança',
dobradinha: 'É dobradinha',
}

View file

@ -0,0 +1,130 @@
/**
* People list filter affordances (C100): Capacidade / Município / Apoio
* toggles, active-filter summary and clear-filters. Multi-select facets are OR
* within the facet, like every other list.
*/
import { leadershipSupportStatuses } from '@/lib/schemas/leadership'
import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
import { PEOPLE_CAPACITIES, peopleCapacityLabels } from '@/utilities/people/peopleLabels'
import {
buildPeopleListHref,
parsePeopleListParams,
peopleListStateToRawParams,
type PeopleListState,
} from '@/utilities/people/peopleListUrl'
export type PeopleFilterOption = {
value: string
label: string
}
type PeopleMultiFilterParam = 'capacity' | 'municipality' | 'status'
export const peopleCapacityFilterOptions: PeopleFilterOption[] = PEOPLE_CAPACITIES.map((value) => ({
value,
label: peopleCapacityLabels[value],
}))
export const peopleStatusFilterOptions: PeopleFilterOption[] = leadershipSupportStatuses.map(
(value) => ({ value, label: supportStatusLabels[value] }),
)
/**
* Writes the RAW param and lets `parsePeopleListParams` validate, so no branch
* has to cast a `string[]` into the state's narrow value types.
*/
const setPeopleMultiFilterValues = (
state: PeopleListState,
param: PeopleMultiFilterParam,
values: string[],
): PeopleListState =>
parsePeopleListParams({
...peopleListStateToRawParams({ ...state, page: 1 }, 1),
[param]: values,
})
const getPeopleMultiFilterValues = (
state: PeopleListState,
param: PeopleMultiFilterParam,
): string[] => {
if (param === 'capacity') return state.capacities ?? []
if (param === 'status') return state.statuses ?? []
return (state.municipalities ?? []).map(String)
}
const togglePeopleMultiFilterValue = (
state: PeopleListState,
param: PeopleMultiFilterParam,
value: string,
): PeopleListState => {
const current = getPeopleMultiFilterValues(state, param)
return setPeopleMultiFilterValues(
state,
param,
current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value],
)
}
export const togglePeopleCapacityFilter = (
state: PeopleListState,
value: string,
): PeopleListState => {
if (!PEOPLE_CAPACITIES.some((capacity) => capacity === value)) return state
return togglePeopleMultiFilterValue(state, 'capacity', value)
}
export const togglePeopleMunicipalityFilter = (
state: PeopleListState,
value: string,
): PeopleListState => {
if (!/^[1-9]\d*$/.test(value)) return state
return togglePeopleMultiFilterValue(state, 'municipality', value)
}
export const togglePeopleStatusFilter = (
state: PeopleListState,
value: string,
): PeopleListState => {
if (!leadershipSupportStatuses.some((status) => status === value)) return state
return togglePeopleMultiFilterValue(state, 'status', value)
}
/** Drop every filter and the search. */
export const clearPeopleListFilters = (): PeopleListState => ({ page: 1 })
export const buildPeopleFilterHref = (next: PeopleListState): string => buildPeopleListHref(next, 1)
const municipalityLabel = (
id: number,
labels?: ReadonlyMap<number, string> | Readonly<Record<number, string>>,
): string => {
if (!labels) return `Município #${id}`
if (labels instanceof Map) return labels.get(id) ?? `Município #${id}`
const record = labels as Readonly<Record<number, string>>
return record[id] ?? `Município #${id}`
}
export const formatPeopleActiveFiltersSummary = (
state: PeopleListState,
municipalityLabelsById?: ReadonlyMap<number, string> | Readonly<Record<number, string>>,
): string | null => {
const parts: string[] = []
if (state.capacities?.length) {
parts.push(
truncatedNamesLabel(state.capacities.map((capacity) => peopleCapacityLabels[capacity])),
)
}
if (state.municipalities?.length) {
parts.push(
truncatedNamesLabel(
state.municipalities.map((id) => municipalityLabel(id, municipalityLabelsById)),
),
)
}
if (state.statuses?.length) {
parts.push(truncatedNamesLabel(state.statuses.map((status) => supportStatusLabels[status])))
}
if (state.q) parts.push(`Busca "${state.q}"`)
return parts.length ? parts.join(' · ') : null
}

View file

@ -0,0 +1,139 @@
/**
* People list URL contract (C100): state, param parse/canonicalize, source-level
* Payload `where`s and hrefs. Own module, same shape as `leadershipListUrl` —
* no sort in v1: the merged list is name-ordered by construction.
*
* The three source-level wheres only cover what a single domain collection can
* express (`q` everywhere, `statuses`/`municipalities` on leadership). Capacity
* and the deputy/staff municipality filters are applied in memory over the
* merged rows (`peopleData.ts`) — reverse relations and cross-source unions.
*/
import type { Where } from 'payload'
import { leadershipSupportStatuses, type SupportStatus } from '@/lib/schemas/leadership'
import {
allParamValues,
buildListHref,
firstValue,
normalizedText,
parseExhaustiveEnumParam,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
import { PEOPLE_CAPACITIES, type PeopleCapacity } from '@/utilities/people/peopleLabels'
export const peoplePageSize = 25
export type PeopleListState = {
page: number
q?: string
capacities?: PeopleCapacity[]
municipalities?: number[]
statuses?: SupportStatus[]
}
export type PeopleListSearchParams = RawSearchParams
const peopleListParamNames = ['q', 'capacity', 'municipality', 'status', 'page'] as const
const peopleListParamNameSet = new Set<string>(peopleListParamNames)
const peopleCapacitySet = new Set<string>(PEOPLE_CAPACITIES)
const supportStatusSet = new Set<string>(leadershipSupportStatuses)
export const parsePeopleListParams = (params: PeopleListSearchParams): PeopleListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const q = normalizedText(firstValue(params.q))
const capacities = parseExhaustiveEnumParam<PeopleCapacity>(params.capacity, peopleCapacitySet)
const municipalities = allParamValues(params.municipality)
.map((token) => strictDecimalInteger(token))
.filter((id): id is number => typeof id === 'number' && id > 0)
const statuses = parseExhaustiveEnumParam<SupportStatus>(params.status, supportStatusSet)
return {
page: rawPage ?? 1,
...(q ? { q } : {}),
...(capacities.length ? { capacities } : {}),
...(municipalities.length ? { municipalities } : {}),
...(statuses.length ? { statuses } : {}),
}
}
export const peopleListStateToRawParams = (
state: PeopleListState,
page = state.page,
): PeopleListSearchParams => ({
page: String(page),
q: state.q,
capacity: state.capacities,
municipality: state.municipalities?.map(String),
status: state.statuses,
})
/** Expects already-canonical state (from parse or a rule-preserving toggle). */
export const serializeCanonicalPeopleListSearchParams = (
canonicalState: PeopleListState,
): URLSearchParams => {
const params = new URLSearchParams()
if (canonicalState.q) params.set('q', canonicalState.q)
for (const capacity of canonicalState.capacities ?? []) params.append('capacity', capacity)
for (const municipality of canonicalState.municipalities ?? []) {
params.append('municipality', String(municipality))
}
for (const status of canonicalState.statuses ?? []) params.append('status', status)
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
return params
}
const buildPeopleListSearchParams = (state: PeopleListState, page = state.page): URLSearchParams =>
serializeCanonicalPeopleListSearchParams(
parsePeopleListParams(peopleListStateToRawParams(state, page)),
)
export const buildPeopleListHref = (state: PeopleListState, page: number): string =>
buildListHref(state, buildPeopleListSearchParams, '/campanha/pessoas', page)
export const resolvePeopleListUrl = (
params: PeopleListSearchParams,
totalPages?: number,
): {
state: PeopleListState
href: string
redirectHref?: string
} =>
resolveListUrl({
params,
paramNameSet: peopleListParamNameSet,
parse: parsePeopleListParams,
buildSearchParams: buildPeopleListSearchParams,
basePath: '/campanha/pessoas',
totalPages,
})
/** Leadership source: `q` + the domain filters a leadership row can express. */
export const buildPeopleLeadershipSourceWhere = (state: PeopleListState): Where => {
const filters: Where[] = []
if (state.q) filters.push({ 'contact.name': { contains: state.q } })
if (state.statuses?.length) filters.push({ supportStatus: { in: state.statuses } })
if (state.municipalities?.length) {
filters.push({ municipalities: { in: state.municipalities } })
}
return filters.length ? { and: filters } : {}
}
/** Dobradinha source: `q` only (municipalities are a reverse relation — in memory). */
export const buildPeopleDeputySourceWhere = (state: PeopleListState): Where => {
const filters: Where[] = []
if (state.q) filters.push({ 'contact.name': { contains: state.q } })
return filters.length ? { and: filters } : {}
}
/** Staff source: every staff account with a ficha (carteira is in memory). */
export const buildPeopleStaffSourceWhere = (state: PeopleListState): Where => {
const filters: Where[] = [
{ role: { in: ['advisor', 'coordinator', 'candidate'] } },
{ contact: { exists: true } },
]
if (state.q) filters.push({ 'contact.name': { contains: state.q } })
return { and: filters }
}

View file

@ -0,0 +1,172 @@
/**
* People list omnibox adapter (C100). Pure / client-safe.
*/
import {
createOmniboxSuggestionSeed,
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
import { peopleCapacityLabels } from '@/utilities/people/peopleLabels'
import {
clearPeopleListFilters,
peopleCapacityFilterOptions,
peopleStatusFilterOptions,
togglePeopleCapacityFilter,
togglePeopleMunicipalityFilter,
togglePeopleStatusFilter,
type PeopleFilterOption,
} from '@/utilities/people/peopleListFilters'
import type { PeopleListState } from '@/utilities/people/peopleListUrl'
export type PeopleOmniboxAction =
| { kind: 'url'; state: PeopleListState }
| { kind: 'clear'; state: PeopleListState }
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
export const buildPeopleOmniboxChips = ({
state,
municipalityLabelsById,
}: {
state: PeopleListState
municipalityLabelsById: ReadonlyMap<number, string>
}): CampaignListOmniboxChip[] => {
const chips: CampaignListOmniboxChip[] = []
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
for (const capacity of state.capacities ?? []) {
chips.push({
id: `capacity:${capacity}`,
label: chipLabel('Capacidade', peopleCapacityLabels[capacity]),
})
}
for (const municipalityId of state.municipalities ?? []) {
chips.push({
id: `municipality:${municipalityId}`,
label: chipLabel(
'Município',
municipalityLabelsById.get(municipalityId) ?? `Município #${municipalityId}`,
),
})
}
for (const status of state.statuses ?? []) {
chips.push({ id: `status:${status}`, label: chipLabel('Apoio', supportStatusLabels[status]) })
}
return chips
}
export const buildPeopleOmniboxSuggestionSeeds = ({
municipalityFilterOptions,
}: {
municipalityFilterOptions: readonly PeopleFilterOption[]
}) => {
const seeds = []
for (const option of peopleCapacityFilterOptions) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `capacity:${option.value}`,
group: 'Capacidade',
label: option.label,
keywords: ['capacidade', 'papel', 'assessora', 'lideranca', 'dobradinha'],
},
{ emptyQueryVisible: true },
),
)
}
for (const option of municipalityFilterOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `municipality:${option.value}`,
group: 'Município',
label: option.label,
keywords: ['municipio'],
}),
)
}
for (const option of peopleStatusFilterOptions) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `status:${option.value}`,
group: 'Apoio',
label: option.label,
keywords: ['apoio', 'status', 'situacao'],
},
{ emptyQueryVisible: true },
),
)
}
return seeds
}
export const filterPeopleOmniboxSuggestions = (
seeds: ReturnType<typeof buildPeopleOmniboxSuggestionSeeds>,
query: string,
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
export const applyPeopleOmniboxSuggestion = ({
state,
suggestionId,
}: {
state: PeopleListState
suggestionId: string
}): PeopleOmniboxAction => {
if (suggestionId.startsWith('q:')) {
const q = suggestionId.slice(2)
return { kind: 'url', state: { ...state, page: 1, q: q || undefined } }
}
if (suggestionId.startsWith('capacity:')) {
return { kind: 'url', state: togglePeopleCapacityFilter(state, suggestionId.slice(9)) }
}
if (suggestionId.startsWith('municipality:')) {
return { kind: 'url', state: togglePeopleMunicipalityFilter(state, suggestionId.slice(13)) }
}
if (suggestionId.startsWith('status:')) {
return { kind: 'url', state: togglePeopleStatusFilter(state, suggestionId.slice(7)) }
}
return { kind: 'url', state }
}
export const removePeopleOmniboxChip = ({
state,
chipId,
}: {
state: PeopleListState
chipId: string
}): PeopleOmniboxAction => {
if (chipId === 'q') return { kind: 'url', state: { ...state, page: 1, q: undefined } }
if (chipId.startsWith('capacity:')) {
return { kind: 'url', state: togglePeopleCapacityFilter(state, chipId.slice(9)) }
}
if (chipId.startsWith('municipality:')) {
return { kind: 'url', state: togglePeopleMunicipalityFilter(state, chipId.slice(13)) }
}
if (chipId.startsWith('status:')) {
return { kind: 'url', state: togglePeopleStatusFilter(state, chipId.slice(7)) }
}
return { kind: 'url', state }
}
export const clearPeopleOmnibox = (): PeopleOmniboxAction => ({
kind: 'clear',
state: clearPeopleListFilters(),
})

View file

@ -0,0 +1,37 @@
/**
* Named bookmarks of the people list URL (C100) — the 2nd call site of the B18
* pattern, specialized per the FD2 veto: this store is concrete and
* device-local, backed by the shared storage factory.
*/
import {
createSavedFilterStore,
SAVED_FILTER_MAX_ENTRIES,
SAVED_FILTER_MAX_NAME_LENGTH,
type CampaignSavedFilter,
type SaveCampaignSavedFilterResult,
} from '@/utilities/campaignSavedFilterStore'
export const STORAGE_KEY = 'teqo:campaign:people-saved-filters'
export type PeopleSavedFilter = CampaignSavedFilter
export type SavePeopleSavedFilterResult = SaveCampaignSavedFilterResult
export { SAVED_FILTER_MAX_ENTRIES as MAX_ENTRIES, SAVED_FILTER_MAX_NAME_LENGTH as MAX_NAME_LENGTH }
const store = createSavedFilterStore({
storageKey: STORAGE_KEY,
isHrefValid: (href) => href.startsWith('/campanha/pessoas'),
})
export const listPeopleSavedFilters = store.list
export const savePeopleSavedFilter: (entry: PeopleSavedFilter) => SavePeopleSavedFilterResult =
store.save
export const removePeopleSavedFilter = store.remove
export const clearPeopleSavedFilters = store.clear
export const subscribePeopleSavedFilters = store.subscribe

View file

@ -0,0 +1,498 @@
import 'server-only'
import type { Payload } from 'payload'
import { relationshipId } from '@/lib/relationship'
import {
PERSON_DELETE_FORBIDDEN_MESSAGE,
PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE,
} from '@/lib/schemas/personDelete'
import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import {
acquireTextAdvisoryLocks,
POSTGRES_DEDUP_LOCK_MESSAGE,
} from '@/utilities/postgresTransactionLocks'
/**
* "Apagar pessoa" (C100): a destructive, transactional cascade from one
* `Contact` ficha across every campaign role, preceded by a read-only manifest
* the confirmation dialog lists verbatim. Coordinator/candidate accounts are
* never deletable — a person holding one is a protected person.
*
* Every `overrideAccess` read/write below is an INTENTIONAL BYPASS justified
* by this module's own authorization: the caller must be an unrestricted actor
* (reloaded fresh by the server action) and the destructive path re-enumerates
* every row it touches inside the transaction before deleting — the manifest
* preview and the protected-account check are the scope proof, the same
* documented pattern `removeSupporterData` established.
*
* The FK map this order respects (verified against the committed schema —
* NOT NULL + `ON DELETE set null` = a delete that would block):
*
* - `vote_pledge.leadership_id`, `campaign_invite.leadership_id` → delete
* pledges and invites BEFORE the leadership rows;
* - `campaign_invite.created_by_id`, `municipality_update.author_id`,
* `calendar_feed.created_by_id`, `supporter_import_batch.actor_id` → rows
* authored by the deleted accounts must go BEFORE the accounts;
* - `campaign_web_authn_credential.user_id`, `notification.recipient_id`,
* `push_subscription.user_id` → cleaned by `campaignUser.beforeDelete`
* (passkeys + notifications), no hand-spelled copy here;
* - every advisor join table (`municipality_rels`, `activity_rels`,
* `leadership_rels`, `state_deputy_rels`) cascades on `campaign_user_id`
* — deleting the accounts removes the carteira/assessorado links;
* - `signature.contact_id`, `subscription.contact_id` → NOT NULL public-site
* joins: when either exists the ficha is ANONYMIZED (LGPD tombstone,
* `removeSupporterData` precedent), never deleted.
*/
type PersonDeletePayload = Pick<Payload, 'find' | 'findByID' | 'delete' | 'update' | 'db'>
export type PersonDeleteManifest = {
contact: { id: number; name: string; phone: string | null; email: string | null }
leaderships: Array<{ id: number; name: string; municipalityNames: string[] }>
stateDeputies: Array<{ id: number; name: string; party: string | null }>
pledgeCount: number
inviteCount: number
supporterCount: number
municipalityUpdateCount: number
calendarFeedCount: number
accounts: Array<{ id: number; name: string; role: CampaignUser['role'] }>
hasProtectedAccount: boolean
/** signature/subscription still reference the ficha → it gets anonymized, not deleted. */
fichaWillBeAnonymized: boolean
}
type DeleteEnumeration = {
leadershipIDs: number[]
accountIDs: number[]
supporterCount: number
signatureCount: number
subscriptionCount: number
}
const enumeratePersonJoins = async (
payload: PersonDeletePayload,
contactID: number,
req?: PayloadTransactionRequest,
): Promise<DeleteEnumeration> => {
const [leaderships, accounts, supporters, signatures, subscriptions] = await Promise.all([
payload.find({
collection: 'leadership',
where: { contact: { equals: contactID } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
req,
}),
payload.find({
collection: 'campaignUser',
where: { contact: { equals: contactID } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
req,
}),
payload.find({
collection: 'supporter',
where: { contact: { equals: contactID } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
req,
}),
payload.find({
collection: 'signature',
where: { contact: { equals: contactID } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
}),
payload.find({
collection: 'subscription',
where: { contact: { equals: contactID } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
}),
])
return {
leadershipIDs: leaderships.docs.map((doc) => doc.id),
accountIDs: accounts.docs.map((doc) => doc.id),
supporterCount: supporters.totalDocs,
signatureCount: signatures.totalDocs,
subscriptionCount: subscriptions.totalDocs,
}
}
const anonymizeContact = async (
payload: PersonDeletePayload,
contactID: number,
req: PayloadTransactionRequest,
): Promise<void> => {
// Must match Contact phone validation: DDD + 9 + 8 digits (`^[1-9]{2}9\d{8}$`).
// Same tombstone as `removeSupporterData` — one per id, so it is unique.
const tombstonePhone = `999${String(contactID).padStart(8, '0')}`
await acquireContactPhoneLocks(payload, req, [tombstonePhone])
await payload.update({
collection: 'contact',
id: contactID,
data: {
name: 'Titular removido',
email: null,
phone: tombstonePhone,
gender: null,
city: null,
postalCode: null,
},
depth: 0,
overrideAccess: true,
req,
})
}
/**
* Read-only enumeration for the confirmation dialog: every row the cascade
* would touch, with the ficha fate spelled out. No writes, no locks.
*/
export const loadPersonDeleteManifest = async (
payload: PersonDeletePayload,
contactID: number,
): Promise<PersonDeleteManifest | null> => {
let contact: Contact
try {
// Intentional admin bypass: the route gate already asserted an unrestricted
// actor; `Contact` read is admin-gated and the manifest is exactly what
// authorizes the destructive confirmation.
contact = await payload.findByID({
collection: 'contact',
id: contactID,
depth: 0,
overrideAccess: true,
})
} catch {
return null
}
const [leaderships, stateDeputies, accounts] = await Promise.all([
payload.find({
collection: 'leadership',
where: { contact: { equals: contactID } },
depth: 1,
limit: 0,
pagination: false,
select: { contact: true, municipalities: true },
overrideAccess: true,
}),
payload.find({
collection: 'stateDeputy',
where: { contact: { equals: contactID } },
depth: 1,
limit: 0,
pagination: false,
select: { contact: true, party: true },
overrideAccess: true,
}),
payload.find({
collection: 'campaignUser',
where: { contact: { equals: contactID } },
depth: 0,
limit: 0,
pagination: false,
select: { name: true, role: true },
overrideAccess: true,
}),
])
const leadershipIDs = leaderships.docs.map((doc) => doc.id)
const accountIDs = accounts.docs.map((doc) => doc.id)
const [pledgeResult, inviteResult, municipalityUpdateResult, calendarFeedResult, joinCounts] =
await Promise.all([
payload.find({
collection: 'votePledge',
where: { leadership: { in: leadershipIDs } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
}),
payload.find({
collection: 'campaignInvite',
where: { leadership: { in: leadershipIDs } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
}),
payload.find({
collection: 'municipalityUpdate',
where: { author: { in: accountIDs } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
}),
payload.find({
collection: 'calendarFeed',
where: { createdBy: { in: accountIDs } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
}),
enumeratePersonJoins(payload, contactID),
])
const municipalityIDs = [
...new Set(
(leaderships.docs as Leadership[]).flatMap((doc) =>
(doc.municipalities ?? []).map(relationshipId).filter((id): id is number => id !== null),
),
),
]
const municipalityNames = new Map<number, string>()
if (municipalityIDs.length > 0) {
const municipalities = await payload.find({
collection: 'municipality',
where: { id: { in: municipalityIDs } },
depth: 0,
limit: 0,
pagination: false,
select: { name: true },
overrideAccess: true,
})
for (const municipality of municipalities.docs) {
municipalityNames.set(municipality.id, municipality.name)
}
}
const leadershipRows = (leaderships.docs as Leadership[]).map((doc) => ({
id: doc.id,
name: typeof doc.contact === 'object' && doc.contact !== null ? doc.contact.name : 'Contato',
municipalityNames: (doc.municipalities ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null)
.map((id) => municipalityNames.get(id))
.filter((name): name is string => name !== undefined),
}))
const deputyRows = (stateDeputies.docs as StateDeputy[]).map((doc) => ({
id: doc.id,
name: typeof doc.contact === 'object' && doc.contact !== null ? doc.contact.name : 'Contato',
party: doc.party ?? null,
}))
return {
contact: {
id: contact.id,
name: contact.name ?? 'Contato',
phone: contact.phone ?? null,
email: contact.email ?? null,
},
leaderships: leadershipRows,
stateDeputies: deputyRows,
pledgeCount: pledgeResult.totalDocs,
inviteCount: inviteResult.totalDocs,
supporterCount: joinCounts.supporterCount,
municipalityUpdateCount: municipalityUpdateResult.totalDocs,
calendarFeedCount: calendarFeedResult.totalDocs,
accounts: accounts.docs.map((doc) => ({
id: doc.id,
name: doc.name,
role: doc.role,
})),
hasProtectedAccount: accounts.docs.some(
(account) => account.role === 'coordinator' || account.role === 'candidate',
),
fichaWillBeAnonymized: joinCounts.signatureCount > 0 || joinCounts.subscriptionCount > 0,
}
}
export type PersonDeleteResult = {
removed: true
contactDeleted: boolean
contactAnonymized: boolean
deletedAccounts: number
}
/**
* The destructive cascade, one transaction, one advisory lock per person.
* Re-enumerates inside the transaction (the manifest is a preview, never the
* authority) and aborts on a coordinator/candidate account.
*/
export const deletePersonRecord = async (
payload: PersonDeletePayload,
actor: CampaignUser,
contactID: number,
): Promise<PersonDeleteResult> =>
withPayloadTransaction(payload, async ({ req }) => {
if (!isCampaignUnrestricted(actor)) {
throw new Error(PERSON_DELETE_FORBIDDEN_MESSAGE)
}
if (payload.db.name !== 'postgres') {
throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE)
}
await acquireTextAdvisoryLocks(payload, req, [`person-delete:${contactID}`])
const { leadershipIDs, accountIDs, supporterCount, signatureCount, subscriptionCount } =
await enumeratePersonJoins(payload, contactID, req)
if (accountIDs.length > 0) {
const accounts = await payload.find({
collection: 'campaignUser',
where: { id: { in: accountIDs } },
depth: 0,
limit: 0,
pagination: false,
select: { role: true },
overrideAccess: true,
req,
})
const protectedAccount = accounts.docs.find(
(account) => account.role === 'coordinator' || account.role === 'candidate',
)
if (protectedAccount) {
throw new Error(PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE)
}
}
// Order matters — see the module contract. All bypasses are justified by
// the enumeration + protected-account check above (removeSupporterData
// precedent): the unrestricted actor's scope was proven before any write.
if (leadershipIDs.length > 0) {
await payload.delete({
collection: 'votePledge',
where: { leadership: { in: leadershipIDs } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'campaignInvite',
where: { leadership: { in: leadershipIDs } },
depth: 0,
overrideAccess: true,
req,
})
}
if (accountIDs.length > 0) {
// Invites the deleted accounts CREATED for other leaderships, authored
// municipality updates and calendar feeds all carry NOT NULL FKs with
// `ON DELETE set null` — they must go before the accounts themselves.
await payload.delete({
collection: 'campaignInvite',
where: { createdBy: { in: accountIDs } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'municipalityUpdate',
where: { author: { in: accountIDs } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'calendarFeed',
where: { createdBy: { in: accountIDs } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'supporterImportBatch',
where: { actor: { in: accountIDs } },
depth: 0,
overrideAccess: true,
req,
})
}
if (supporterCount > 0) {
await payload.delete({
collection: 'supporter',
where: { contact: { equals: contactID } },
depth: 0,
overrideAccess: true,
req,
})
}
if (leadershipIDs.length > 0) {
await payload.delete({
collection: 'leadership',
where: { contact: { equals: contactID } },
depth: 0,
overrideAccess: true,
req,
})
}
{
const deputies = await payload.find({
collection: 'stateDeputy',
where: { contact: { equals: contactID } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
req,
})
if (deputies.docs.length > 0) {
await payload.delete({
collection: 'stateDeputy',
where: { id: { in: deputies.docs.map((doc) => doc.id) } },
depth: 0,
overrideAccess: true,
req,
})
}
}
// Per-id so `CampaignUser.beforeDelete` (passkeys + notifications) runs for
// every account — bulk `where` deletes would skip the hooks.
for (const accountID of accountIDs) {
await payload.delete({
collection: 'campaignUser',
id: accountID,
depth: 0,
overrideAccess: true,
req,
})
}
let contactDeleted = false
let contactAnonymized = false
if (signatureCount > 0 || subscriptionCount > 0) {
await anonymizeContact(payload, contactID, req)
contactAnonymized = true
} else {
await payload.delete({
collection: 'contact',
id: contactID,
depth: 0,
overrideAccess: true,
req,
})
contactDeleted = true
}
return {
removed: true,
contactDeleted,
contactAnonymized,
deletedAccounts: accountIDs.length,
}
})

View file

@ -0,0 +1,56 @@
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* C100 — the unified people list: staff lands on `/campanha/pessoas`, sees the
* merged person row, and the route stays locked for leaders.
*/
test.describe('Pessoas — lista unificada', () => {
test('staff opens the people list and sees the merged leadership person', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Pessoas'),
})
const municipality = await fixtures.claimMunicipality()
const { contactName } = await fixtures.createStaffLeadership({
namePrefix: 'Liderança Pessoas',
municipalities: [municipality],
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.getByRole('link', { name: 'Pessoas', exact: true }).click()
await expect(page).toHaveURL(/\/campanha\/pessoas$/)
await expect(campaignPageChrome(page, 'Pessoas')).toBeVisible()
// The merged row carries the leadership person with its capacity chips
// (desktop table — the mobile card twin is `md:hidden` at this viewport).
await expect(page.getByRole('row', { name: new RegExp(contactName) })).toBeVisible()
// The coordinator account itself is a staff person (with a ficha) — ≥ 2 rows.
await expect(page.getByText(/^\d+ pessoas?$/)).toBeVisible()
// The omnibox search narrows the recorte (canonical URL, chips mounted).
const omnibox = page.getByRole('combobox', { name: 'Filtrar pessoas' })
await omnibox.fill(contactName.slice(0, 8))
await omnibox.press('Enter')
await expect(page).toHaveURL(/q=/)
await expect(page.getByRole('row', { name: new RegExp(contactName) })).toBeVisible()
})
test('leader cannot open the people page', async ({ campaign, page }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança Sem Acesso'),
username: phone,
})
await campaign.login(page, phone, leader.password)
await page.goto('/campanha/pessoas')
await expect(page).toHaveURL(/\/campanha\/contatos/)
await expect(page.getByRole('heading', { name: 'Contatos' })).toBeVisible()
})
})

View file

@ -0,0 +1,154 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { loadPeopleListPageData } from '@/utilities/people/peopleData'
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('C100 — lista unificada de pessoas (merge por Contact)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('merges leadership, dobradinha and staff rows into one row per ficha', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const leadershipContact = await fixtures.createContact({ name: 'Maria de Jesus' })
const leadership = await fixtures.createLeadership({
contact: leadershipContact,
municipalities: [municipality],
})
const deputyContact = await fixtures.createContact({ name: 'Ana Lima' })
const deputy = await fixtures.createStateDeputy({ contact: deputyContact, party: 'PCdoB' })
const advisor = await fixtures.createCampaignUser('advisor')
const pureContact = await fixtures.createContact({ name: 'Só Ficha' })
const result = await loadPeopleListPageData(payload, coordinator, { page: 1 })
const maria = result.rows.find((row) => row.contactID === relationId(leadershipContact))
expect(maria).toMatchObject({
name: 'Maria de Jesus',
leadershipID: leadership.id,
supportStatus: 'engajado',
})
expect(maria?.leadershipMunicipalityIDs).toContain(municipality.id)
const ana = result.rows.find((row) => row.contactID === relationId(deputyContact))
expect(ana).toMatchObject({ name: 'Ana Lima', deputyID: deputy.id, party: 'PCdoB' })
// Staff with a ficha enters even with an EMPTY carteira (gate 2026-08-09).
const advisorRow = result.rows.find((row) => row.contactID === relationId(advisor.contact))
expect(advisorRow?.staff.map((account) => account.id)).toContain(advisor.id)
expect(advisorRow?.assessoraMunicipalityIDs).toEqual([])
// A bare Contact with no role never enters.
expect(result.rows.some((row) => row.contactID === relationId(pureContact))).toBe(false)
expect(result.filterFacets.municipalityIDs).toContain(municipality.id)
expect(result.filterFacets.statuses).toContain('engajado')
})
it('merges an account sharing the ficha (phone reuse) into the leadership row', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact({ name: 'Dupla Função' })
const leadership = await fixtures.createLeadership({
contact,
municipalities: [municipality],
})
const account = await fixtures.createCampaignUser('advisor', { phone: contact.phone })
expect(relationId(account.contact)).toBe(contact.id)
const result = await loadPeopleListPageData(payload, coordinator, { page: 1 })
const rows = result.rows.filter((row) => row.contactID === contact.id)
expect(rows).toHaveLength(1)
expect(rows[0]?.leadershipID).toBe(leadership.id)
expect(rows[0]?.staff.some((staff) => staff.id === account.id)).toBe(true)
})
it('scopes every source to the advisor carteira', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const administered = await fixtures.getMunicipality()
const stranger = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(administered, [advisor])
const ownContact = await fixtures.createContact({ name: 'Própria Liderança' })
await fixtures.createLeadership({ contact: ownContact, municipalities: [administered] })
const alienContact = await fixtures.createContact({ name: 'Liderança Alheia' })
await fixtures.createLeadership({ contact: alienContact, municipalities: [stranger] })
const deputyContact = await fixtures.createContact({ name: 'Aliada Local' })
const deputy = await fixtures.createStateDeputy({ contact: deputyContact, party: 'PT' })
fixtures.touchMunicipality(administered)
await payload.update({
collection: 'municipality',
id: administered.id,
data: { stateDeputies: [deputy.id] },
depth: 0,
})
const colleague = await fixtures.createCampaignUser('advisor')
const unassigned = await fixtures.createCampaignUser('advisor')
await fixtures.assignMunicipalityAdvisors(administered, [advisor, colleague])
const result = await loadPeopleListPageData(payload, advisor, { page: 1 })
const contactIDs = result.rows.map((row) => row.contactID)
expect(contactIDs).toContain(relationId(ownContact))
expect(contactIDs).not.toContain(relationId(alienContact))
expect(contactIDs).toContain(relationId(deputyContact))
expect(contactIDs).toContain(relationId(colleague.contact))
// The advisor himself has a carteira in the portfolio — he sees himself.
expect(contactIDs).toContain(relationId(advisor.contact))
// A staff account with an EMPTY carteira is outside every portfolio.
expect(contactIDs).not.toContain(relationId(unassigned.contact))
})
it('searches by name across the sources', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const maria = await fixtures.createContact({ name: 'Maria de Jesus' })
await fixtures.createLeadership({ contact: maria, municipalities: [municipality] })
const joao = await fixtures.createContact({ name: 'João do Brejo' })
await fixtures.createLeadership({ contact: joao, municipalities: [municipality] })
const result = await loadPeopleListPageData(payload, coordinator, { page: 1, q: 'Maria' })
const names = result.rows.map((row) => row.name)
expect(names).toContain('Maria de Jesus')
expect(names).not.toContain('João do Brejo')
})
it('returns empty data for a leader (lockdown)', async () => {
const fixtures = campaignFixtures()
const leader = await fixtures.createCampaignUser('leader')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact({ name: 'Alguém' })
await fixtures.createLeadership({ contact, municipalities: [municipality] })
const result = await loadPeopleListPageData(payload, leader, { page: 1 })
expect(result.rows).toEqual([])
expect(result.totalDocs).toBe(0)
expect(result.totalPages).toBe(0)
})
})

View file

@ -0,0 +1,182 @@
// @vitest-environment node
import type { Payload, Where } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import {
PERSON_DELETE_FORBIDDEN_MESSAGE,
PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE,
} from '@/lib/schemas/personDelete'
import config from '@/payload.config'
import { deletePersonRecord, loadPersonDeleteManifest } from '@/utilities/people/personDelete'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const countRemaining = async (
collection:
| 'contact'
| 'leadership'
| 'stateDeputy'
| 'campaignUser'
| 'votePledge'
| 'supporter',
where: Where,
): Promise<number> => {
const result = await payload.find({
collection,
where,
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
return result.totalDocs
}
describe('C100 — apagar pessoa (manifest + cascata)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('lists every cascade target in the manifest', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact({ name: 'Maria de Jesus' })
const leadership = await fixtures.createLeadership({ contact, municipalities: [municipality] })
await fixtures.createVotePledge({ leadership, municipality })
const advisor = await fixtures.createCampaignUser('advisor', { phone: contact.phone })
await fixtures.createStateDeputy({ contact, party: 'PT' })
await fixtures.createInvite({ leadership, createdBy: coordinator })
await fixtures.createSupporter({ contact })
const manifest = await loadPersonDeleteManifest(payload, contact.id)
expect(manifest).not.toBeNull()
expect(manifest?.contact.name).toBe('Maria de Jesus')
expect(manifest?.leaderships).toHaveLength(1)
expect(manifest?.leaderships[0]?.municipalityNames).toContain(municipality.name)
expect(manifest?.stateDeputies).toHaveLength(1)
expect(manifest?.stateDeputies[0]?.party).toBe('PT')
expect(manifest?.pledgeCount).toBe(1)
expect(manifest?.inviteCount).toBe(1)
expect(manifest?.supporterCount).toBe(1)
expect(manifest?.accounts.map((account) => account.id)).toContain(advisor.id)
expect(manifest?.hasProtectedAccount).toBe(false)
expect(manifest?.fichaWillBeAnonymized).toBe(false)
})
it('cascade-deletes every campaign row and the ficha', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact({ name: 'João do Brejo' })
const leadership = await fixtures.createLeadership({ contact, municipalities: [municipality] })
await fixtures.createVotePledge({ leadership, municipality })
await fixtures.createStateDeputy({ contact, party: 'PCdoB' })
const advisor = await fixtures.createCampaignUser('advisor', { phone: contact.phone })
await fixtures.createSupporter({ contact })
const result = await deletePersonRecord(payload, coordinator, contact.id)
expect(result.removed).toBe(true)
expect(result.contactDeleted).toBe(true)
expect(result.contactAnonymized).toBe(false)
expect(result.deletedAccounts).toBe(1)
expect(await countRemaining('contact', { id: { equals: contact.id } })).toBe(0)
expect(await countRemaining('leadership', { id: { equals: leadership.id } })).toBe(0)
expect(await countRemaining('stateDeputy', { contact: { equals: contact.id } })).toBe(0)
expect(await countRemaining('campaignUser', { id: { equals: advisor.id } })).toBe(0)
expect(await countRemaining('votePledge', { leadership: { equals: leadership.id } })).toBe(0)
expect(await countRemaining('supporter', { contact: { equals: contact.id } })).toBe(0)
})
it('anonymizes the ficha when a public join still references it', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact({ name: 'Ana Lima' })
await fixtures.createLeadership({ contact, municipalities: [municipality] })
const consent = await fixtures.createConsent()
const subscription = await payload.create({
collection: 'subscription',
data: { contact: contact.id, consent: consent.id },
depth: 0,
})
try {
const manifest = await loadPersonDeleteManifest(payload, contact.id)
expect(manifest?.fichaWillBeAnonymized).toBe(true)
const result = await deletePersonRecord(payload, coordinator, contact.id)
expect(result.contactDeleted).toBe(false)
expect(result.contactAnonymized).toBe(true)
const remaining = await payload.findByID({
collection: 'contact',
id: contact.id,
depth: 0,
overrideAccess: true,
})
expect(remaining.name).toBe('Titular removido')
expect(remaining.phone).toMatch(/^999\d{8}$/)
expect(remaining.email).toBeNull()
expect(remaining.city).toBeNull()
} finally {
await payload.delete({
collection: 'subscription',
id: subscription.id,
depth: 0,
overrideAccess: true,
})
}
})
it('refuses to delete a person holding a coordinator account', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact({ name: 'Protegida' })
await fixtures.createLeadership({ contact, municipalities: [municipality] })
await fixtures.createCampaignUser('coordinator', { phone: contact.phone })
const manifest = await loadPersonDeleteManifest(payload, contact.id)
expect(manifest?.hasProtectedAccount).toBe(true)
await expect(deletePersonRecord(payload, coordinator, contact.id)).rejects.toThrow(
PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE,
)
expect(await countRemaining('leadership', { contact: { equals: contact.id } })).toBe(1)
})
it('refuses non-unrestricted actors', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact({ name: 'Alguém' })
await fixtures.createLeadership({ contact, municipalities: [municipality] })
await expect(deletePersonRecord(payload, advisor, contact.id)).rejects.toThrow(
PERSON_DELETE_FORBIDDEN_MESSAGE,
)
})
it('returns a null manifest for an unknown contact', async () => {
const fixtures = campaignFixtures()
await fixtures.createCampaignUser('coordinator')
const manifest = await loadPersonDeleteManifest(payload, 999999999)
expect(manifest).toBeNull()
})
})

View file

@ -420,6 +420,9 @@ describe('src/utilities top-level is pinned', () => {
'campaignInviteRepository.ts',
'campaignJsonMutationRoute.ts',
'campaignListUrl.ts',
// C100 — shared storage primitive behind the B18 saved-filters stores
// (municipality + people): pure client-side factory, no Payload/Next.
'campaignSavedFilterStore.ts',
'campaignPageActor.ts',
'campaignPasswordReset.ts',
'campaignPwa.ts',
@ -549,6 +552,10 @@ describe('advisor scope fragment comes from access/shared.ts', () => {
'src/utilities/leadership/leadershipData.ts',
'src/utilities/leadership/leadershipListUrl.ts',
'src/utilities/municipality/municipalityTriggers.ts',
// C100 — the people list filters the leadership SOURCE by municipality ids
// at query level (the same distinct form as `leadershipListUrl.ts`); the
// actor scope is enforced by the merge, never by this where.
'src/utilities/people/peopleListUrl.ts',
// B155 — the municipality list surface reads `leadership.municipalities`
// by reverse batch; same distinct form as `leadershipData.ts` above.
'src/utilities/municipality/municipalityViewModels.ts',

View file

@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import {
buildPeopleDeputySourceWhere,
buildPeopleLeadershipSourceWhere,
buildPeopleListHref,
buildPeopleStaffSourceWhere,
parsePeopleListParams,
resolvePeopleListUrl,
serializeCanonicalPeopleListSearchParams,
} from '@/utilities/people/peopleListUrl'
describe('people list URL contract', () => {
it('defaults to page 1 with no filters', () => {
expect(parsePeopleListParams({})).toEqual({ page: 1 })
})
it('validates page as a positive decimal integer', () => {
expect(parsePeopleListParams({ page: '4' }).page).toBe(4)
expect(parsePeopleListParams({ page: '0' }).page).toBe(1)
expect(parsePeopleListParams({ page: '-2' }).page).toBe(1)
expect(parsePeopleListParams({ page: 'abc' }).page).toBe(1)
})
it('trims q, omits it when empty and takes the first repeated cell', () => {
expect(parsePeopleListParams({ q: ' ana ' }).q).toBe('ana')
expect(parsePeopleListParams({ q: ' ' }).q).toBeUndefined()
expect(parsePeopleListParams({ q: ['primeiro', 'segundo'] }).q).toBe('primeiro')
})
it('parses and dedupes known capacity values', () => {
expect(parsePeopleListParams({ capacity: 'assessora' }).capacities).toEqual(['assessora'])
expect(
parsePeopleListParams({ capacity: ['lideranca', 'dobradinha', 'lideranca', 'unknown'] })
.capacities,
).toEqual(['lideranca', 'dobradinha'])
})
it('treats selecting every capacity as no filter (canonical "todas")', () => {
const all = parsePeopleListParams({ capacity: ['assessora', 'lideranca', 'dobradinha'] })
expect(all.capacities).toBeUndefined()
})
it('parses and dedupes integer municipality ids and known status values', () => {
expect(
parsePeopleListParams({
municipality: ['12', '12', '0', 'abc', '3'],
status: ['engajado', 'em_disputa', 'unknown'],
}),
).toEqual({ page: 1, municipalities: [12, 3], statuses: ['engajado', 'em_disputa'] })
})
it('serializes canonical params in stable order and omits defaults', () => {
const params = serializeCanonicalPeopleListSearchParams({
page: 1,
q: 'ana',
capacities: ['lideranca'],
municipalities: [3, 12],
statuses: ['engajado'],
})
expect(params.toString()).toBe(
'q=ana&capacity=lideranca&municipality=3&municipality=12&status=engajado',
)
})
it('builds hrefs through the canonical serializer and drops page 1', () => {
expect(buildPeopleListHref({ page: 1, capacities: ['dobradinha'] }, 1)).toBe(
'/campanha/pessoas?capacity=dobradinha',
)
expect(buildPeopleListHref({ page: 3 }, 3)).toBe('/campanha/pessoas?page=3')
})
it('resolves a bare URL to the bare path and rejects unsupported params', () => {
const resolved = resolvePeopleListUrl({ q: 'ana' })
expect(resolved.state).toEqual({ page: 1, q: 'ana' })
expect(resolved.href).toBe('/campanha/pessoas?q=ana')
expect(resolved.redirectHref).toBeUndefined()
const unsupported = resolvePeopleListUrl({ q: 'ana', sort: 'name' })
expect(unsupported.redirectHref).toBe('/campanha/pessoas?q=ana')
})
it('clamps an out-of-range page to totalPages', () => {
const resolved = resolvePeopleListUrl({ page: '9' }, 3)
expect(resolved.state.page).toBe(3)
expect(resolved.redirectHref).toBe('/campanha/pessoas?page=3')
})
})
describe('people list source-level wheres', () => {
it('builds the leadership where from q, statuses and municipalities', () => {
expect(buildPeopleLeadershipSourceWhere({ page: 1 })).toEqual({})
expect(
buildPeopleLeadershipSourceWhere({
page: 1,
q: 'ana',
statuses: ['engajado'],
municipalities: [3],
}),
).toEqual({
and: [
{ 'contact.name': { contains: 'ana' } },
{ supportStatus: { in: ['engajado'] } },
{ municipalities: { in: [3] } },
],
})
})
it('restricts the deputy where to q only', () => {
expect(buildPeopleDeputySourceWhere({ page: 1, q: 'ana', statuses: ['engajado'] })).toEqual({
and: [{ 'contact.name': { contains: 'ana' } }],
})
})
it('always restricts the staff source to staff roles with a ficha', () => {
expect(buildPeopleStaffSourceWhere({ page: 1 })).toEqual({
and: [
{ role: { in: ['advisor', 'coordinator', 'candidate'] } },
{ contact: { exists: true } },
],
})
expect(buildPeopleStaffSourceWhere({ page: 1, q: 'ana' }).and).toContainEqual({
'contact.name': { contains: 'ana' },
})
})
})

View file

@ -0,0 +1,224 @@
import { describe, expect, it } from 'vitest'
import {
filterPeopleRows,
mergePeopleSources,
peopleFilterFacetsFromRows,
scopePeopleRows,
type MergedPerson,
type PeopleMergeSources,
} from '@/utilities/people/peopleData'
const source: PeopleMergeSources = {
leaderships: [
{
id: 10,
contactID: 100,
name: 'Maria Souza',
phone: '71999990001',
email: 'maria@example.com',
city: 'Feira de Santana',
municipalityIDs: [3, 4],
supportStatus: 'engajado',
hasAppAccess: true,
advisorIDs: [21],
},
],
deputies: [
{
id: 20,
contactID: 101,
name: 'Ana Lima',
phone: null,
email: 'ana@example.com',
city: 'Camaçari',
party: 'PCdoB',
municipalityIDs: [5],
advisorIDs: [22, 23],
},
],
staff: [
{
id: 31,
contactID: 100,
name: 'Maria Souza',
phone: '71999990001',
email: 'maria@example.com',
city: 'Feira de Santana',
role: 'advisor',
municipalityIDs: [3, 6],
},
{
id: 32,
contactID: 102,
name: 'João Pereira',
phone: null,
email: null,
city: null,
role: 'advisor',
municipalityIDs: [],
},
{
id: 33,
contactID: 103,
name: 'Jorge Solla',
phone: null,
email: null,
city: null,
role: 'candidate',
municipalityIDs: [1],
},
],
}
const byContact = (rows: readonly MergedPerson[], contactID: number): MergedPerson => {
const row = rows.find((person) => person.contactID === contactID)
if (!row) throw new Error(`Missing merged person ${contactID}`)
return row
}
describe('mergePeopleSources (C100)', () => {
it('merges one row per contact, accumulating capacities', () => {
const rows = mergePeopleSources(source)
expect(rows.map((row) => row.contactID).sort()).toEqual([100, 101, 102, 103])
const maria = byContact(rows, 100)
expect(maria.name).toBe('Maria Souza')
expect(maria.phone).toBe('71999990001')
expect(maria.email).toBe('maria@example.com')
expect(maria.city).toBe('Feira de Santana')
expect(maria.leadershipID).toBe(10)
expect(maria.leadershipMunicipalityIDs).toEqual([3, 4])
expect(maria.supportStatus).toBe('engajado')
expect(maria.hasAppAccess).toBe(true)
expect(maria.staff.map((account) => account.id)).toEqual([31])
expect(maria.assessoraMunicipalityIDs).toEqual([3, 6])
expect(maria.capacityMunicipalityIDs).toEqual([3, 4, 6])
})
it('carries the party from the dobradinha and keeps its municipalities', () => {
const ana = byContact(mergePeopleSources(source), 101)
expect(ana.party).toBe('PCdoB')
expect(ana.deputyID).toBe(20)
expect(ana.deputyMunicipalityIDs).toEqual([5])
expect(ana.capacityMunicipalityIDs).toEqual([5])
})
it('keeps a staff account with an EMPTY carteira as a capacity (gate 2026-08-09)', () => {
const joao = byContact(mergePeopleSources(source), 102)
expect(joao.staff).toHaveLength(1)
expect(joao.assessoraMunicipalityIDs).toEqual([])
expect(joao.capacityMunicipalityIDs).toEqual([])
})
it('sorts and dedupes the capacity municipality unions', () => {
const maria = byContact(mergePeopleSources(source), 100)
expect(maria.assessoraMunicipalityIDs).toEqual([3, 6])
expect(maria.capacityMunicipalityIDs).toEqual([3, 4, 6])
})
it('merges multiple staff accounts sharing one ficha into one row', () => {
const rows = mergePeopleSources({
leaderships: [],
deputies: [],
staff: [
{
id: 41,
contactID: 200,
name: 'Dupla',
phone: null,
email: null,
city: null,
role: 'advisor',
municipalityIDs: [1],
},
{
id: 42,
contactID: 200,
name: 'Dupla',
phone: null,
email: null,
city: null,
role: 'coordinator',
municipalityIDs: [2],
},
],
})
const person = byContact(rows, 200)
expect(person.staff.map((account) => account.id).sort()).toEqual([41, 42])
expect(person.assessoraMunicipalityIDs).toEqual([1, 2])
expect(rows).toHaveLength(1)
})
})
describe('scopePeopleRows (advisor viewer)', () => {
it('keeps every row for unrestricted actors', () => {
const rows = mergePeopleSources(source)
expect(scopePeopleRows(rows, null)).toHaveLength(4)
})
it('keeps rows with any capacity municipality in the portfolio', () => {
const rows = mergePeopleSources(source)
const scoped = scopePeopleRows(rows, new Set([6]))
expect(scoped.map((row) => row.contactID).sort()).toEqual([100])
})
it('drops rows without any capacity municipality in the portfolio', () => {
const rows = mergePeopleSources(source)
const scoped = scopePeopleRows(rows, new Set([1]))
expect(scoped.map((row) => row.contactID)).toEqual([103])
})
it('hides staff with an empty carteira from every advisor portfolio', () => {
const rows = mergePeopleSources(source)
const scoped = scopePeopleRows(rows, new Set([3, 4, 5, 6]))
expect(scoped.some((row) => row.contactID === 102)).toBe(false)
})
})
describe('filterPeopleRows', () => {
const rows = mergePeopleSources(source)
it('filters by capacity with OR semantics within the facet', () => {
expect(
filterPeopleRows(rows, { page: 1, capacities: ['lideranca'] }).map((row) => row.contactID),
).toEqual([100])
expect(
filterPeopleRows(rows, { page: 1, capacities: ['lideranca', 'dobradinha'] })
.map((row) => row.contactID)
.sort(),
).toEqual([100, 101])
expect(
filterPeopleRows(rows, { page: 1, capacities: ['assessora'] })
.map((row) => row.contactID)
.sort(),
).toEqual([100, 103])
})
it('filters by municipality intersecting any capacity', () => {
expect(
filterPeopleRows(rows, { page: 1, municipalities: [5] }).map((row) => row.contactID),
).toEqual([101])
expect(
filterPeopleRows(rows, { page: 1, municipalities: [3] }).map((row) => row.contactID),
).toEqual([100])
expect(filterPeopleRows(rows, { page: 1, municipalities: [99] })).toEqual([])
})
it('filters by leadership status, dropping non-leadership rows', () => {
expect(
filterPeopleRows(rows, { page: 1, statuses: ['engajado'] }).map((row) => row.contactID),
).toEqual([100])
expect(filterPeopleRows(rows, { page: 1, statuses: ['negativo'] })).toEqual([])
})
})
describe('peopleFilterFacetsFromRows', () => {
it('unions the capacity municipalities and statuses of the scoped rows with the selected values', () => {
const rows = mergePeopleSources(source)
const facets = peopleFilterFacetsFromRows(rows, { page: 1, municipalities: [99] })
expect(facets.municipalityIDs).toEqual([1, 3, 4, 5, 6, 99])
expect(facets.statuses).toEqual(['engajado'])
})
})

View file

@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearPeopleSavedFilters,
listPeopleSavedFilters,
removePeopleSavedFilter,
savePeopleSavedFilter,
STORAGE_KEY,
subscribePeopleSavedFilters,
} from '@/utilities/people/peopleSavedFilters'
/**
* C100 saved-filter store — the same behavioral contract as the B18
* municipality suite, exercised through the shared `createSavedFilterStore`
* factory (the municipality module keeps its own pinned suite).
*/
const hrefFor = (page = 1): string => `/campanha/pessoas?q=${page}`
const stub = (entries: unknown): void => {
if (entries === null) localStorage.removeItem(STORAGE_KEY)
else localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
}
describe('peopleSavedFilters storage', () => {
beforeEach(() => {
clearPeopleSavedFilters()
vi.spyOn(Storage.prototype, 'setItem')
vi.spyOn(Storage.prototype, 'removeItem')
})
it('returns an empty list for an empty store', () => {
expect(listPeopleSavedFilters()).toEqual([])
stub('not-json')
expect(listPeopleSavedFilters()).toEqual([])
stub({ href: '/campanha/pessoas' })
expect(listPeopleSavedFilters()).toEqual([])
})
it('saves entries alphabetically and rejects foreign hrefs', () => {
savePeopleSavedFilter({ href: hrefFor(1), name: 'Zona da Mata' })
savePeopleSavedFilter({ href: '/campanha/municipios?q=x', name: 'De outra lista' })
savePeopleSavedFilter({ href: hrefFor(2), name: 'Àgua Fria' })
expect(listPeopleSavedFilters().map((entry) => entry.name)).toEqual([
'Àgua Fria',
'Zona da Mata',
])
expect(
listPeopleSavedFilters().every((entry) => entry.href.startsWith('/campanha/pessoas')),
).toBe(true)
})
it('renames an entry with the same href instead of duplicating it', () => {
savePeopleSavedFilter({ href: hrefFor(1), name: 'Primeiro nome' })
expect(savePeopleSavedFilter({ href: hrefFor(1), name: 'Segundo nome' })).toBe('saved')
expect(listPeopleSavedFilters()).toEqual([{ href: hrefFor(1), name: 'Segundo nome' }])
})
it('trims and caps the name', () => {
savePeopleSavedFilter({ href: hrefFor(1), name: ` ${'a'.repeat(80)} ` })
expect(listPeopleSavedFilters()[0]?.name).toBe('a'.repeat(60))
})
it('refuses a blank name', () => {
expect(savePeopleSavedFilter({ href: hrefFor(1), name: ' ' })).toBe('failed')
expect(listPeopleSavedFilters()).toEqual([])
})
it('refuses a save past the entry cap, but still allows renames', () => {
for (let index = 0; index < 12; index += 1) {
savePeopleSavedFilter({ href: hrefFor(index), name: `Recorte ${index}` })
}
expect(savePeopleSavedFilter({ href: hrefFor(99), name: 'Um a mais' })).toBe('limit')
expect(listPeopleSavedFilters()).toHaveLength(12)
expect(savePeopleSavedFilter({ href: hrefFor(0), name: 'Renomeado no teto' })).toBe('saved')
expect(listPeopleSavedFilters().some((entry) => entry.name === 'Renomeado no teto')).toBe(true)
})
it('removes and clears entries', () => {
savePeopleSavedFilter({ href: hrefFor(1), name: 'Um' })
savePeopleSavedFilter({ href: hrefFor(2), name: 'Dois' })
removePeopleSavedFilter(hrefFor(1))
expect(listPeopleSavedFilters()).toEqual([{ href: hrefFor(2), name: 'Dois' }])
clearPeopleSavedFilters()
expect(listPeopleSavedFilters()).toEqual([])
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
})
it('caches the snapshot until a write and notifies subscribers', () => {
savePeopleSavedFilter({ href: hrefFor(1), name: 'Um' })
const first = listPeopleSavedFilters()
expect(listPeopleSavedFilters()).toBe(first)
const subscriber = vi.fn()
const unsubscribe = subscribePeopleSavedFilters(subscriber)
savePeopleSavedFilter({ href: hrefFor(2), name: 'Dois' })
expect(subscriber).toHaveBeenCalledTimes(1)
expect(listPeopleSavedFilters()).not.toBe(first)
unsubscribe()
})
})