From cc7dff88c4119056e2c719dcf49a1f849d25e659 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 00:59:32 -0300 Subject: [PATCH] feat(C100): lista unificada de pessoas em /campanha/pessoas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge por Contact (uma linha por pessoa física) de lideranças, dobradinhas e staff, com capacidades como colunas; omnibox com capacidade/município/apoio + filtros salvos (2º call site do B18 via factory createSavedFilterStore); apagar pessoa com manifest de confirmação e cascata transacional; mobile edge-to-edge (omnibox sem bordas, cards com divisórias, salvar filtro no header). Sem migration. Closes #495 --- docs/CHANGELOG-AGENTS.md | 3 + docs/plans/pessoas-lista-unificada-impl.md | 137 +++++ scripts/lib/e2e-affected-manifest.mjs | 9 + .../campanha/(app)/pessoas/loading.tsx | 20 + .../campanha/(app)/pessoas/page.tsx | 408 ++++++++++++++ src/app/(campaign)/campanha/actions/person.ts | 36 ++ .../campaign/people/DeletePersonButton.tsx | 261 +++++++++ .../campaign/people/PeopleFilters.tsx | 134 +++++ .../campaign/people/PeopleListPageChrome.tsx | 25 + .../campaign/people/PeopleNavSavedFilters.tsx | 154 ++++++ .../people/SavePeopleFilterControl.tsx | 144 +++++ .../campaign/shared/CampaignListOmnibox.tsx | 15 +- .../campaign/shared/usePeopleSavedFilters.ts | 43 ++ .../campaign/shell/CampaignBottomNav.tsx | 4 + .../campaign/shell/CampaignSidebar.tsx | 7 + src/components/campaign/shell/nav.ts | 8 + src/lib/campaignColumnVisibility.ts | 1 + src/lib/campaignPageChrome.ts | 7 + src/lib/schemas/personDelete.ts | 17 + src/utilities/campaignSavedFilterStore.ts | 164 ++++++ .../municipality/municipalitySavedFilters.ts | 157 +----- src/utilities/people/peopleData.ts | 474 +++++++++++++++++ src/utilities/people/peopleLabels.ts | 15 + src/utilities/people/peopleListFilters.ts | 130 +++++ src/utilities/people/peopleListUrl.ts | 139 +++++ src/utilities/people/peopleOmnibox.ts | 172 ++++++ src/utilities/people/peopleSavedFilters.ts | 37 ++ src/utilities/people/personDelete.ts | 498 ++++++++++++++++++ tests/e2e/campaignPeople.e2e.spec.ts | 56 ++ tests/int/peopleList.int.spec.ts | 154 ++++++ tests/int/personDelete.int.spec.ts | 182 +++++++ tests/unit/codebaseConventions.unit.spec.ts | 7 + tests/unit/peopleListUrl.unit.spec.ts | 126 +++++ tests/unit/peopleMerge.unit.spec.ts | 224 ++++++++ tests/unit/peopleSavedFilters.unit.spec.ts | 100 ++++ 35 files changed, 3936 insertions(+), 132 deletions(-) create mode 100644 docs/plans/pessoas-lista-unificada-impl.md create mode 100644 src/app/(campaign)/campanha/(app)/pessoas/loading.tsx create mode 100644 src/app/(campaign)/campanha/(app)/pessoas/page.tsx create mode 100644 src/app/(campaign)/campanha/actions/person.ts create mode 100644 src/components/campaign/people/DeletePersonButton.tsx create mode 100644 src/components/campaign/people/PeopleFilters.tsx create mode 100644 src/components/campaign/people/PeopleListPageChrome.tsx create mode 100644 src/components/campaign/people/PeopleNavSavedFilters.tsx create mode 100644 src/components/campaign/people/SavePeopleFilterControl.tsx create mode 100644 src/components/campaign/shared/usePeopleSavedFilters.ts create mode 100644 src/lib/schemas/personDelete.ts create mode 100644 src/utilities/campaignSavedFilterStore.ts create mode 100644 src/utilities/people/peopleData.ts create mode 100644 src/utilities/people/peopleLabels.ts create mode 100644 src/utilities/people/peopleListFilters.ts create mode 100644 src/utilities/people/peopleListUrl.ts create mode 100644 src/utilities/people/peopleOmnibox.ts create mode 100644 src/utilities/people/peopleSavedFilters.ts create mode 100644 src/utilities/people/personDelete.ts create mode 100644 tests/e2e/campaignPeople.e2e.spec.ts create mode 100644 tests/int/peopleList.int.spec.ts create mode 100644 tests/int/personDelete.int.spec.ts create mode 100644 tests/unit/peopleListUrl.unit.spec.ts create mode 100644 tests/unit/peopleMerge.unit.spec.ts create mode 100644 tests/unit/peopleSavedFilters.unit.spec.ts diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index fd75c08c..a9039a9d 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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:` — 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 `` 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 ``). 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). diff --git a/docs/plans/pessoas-lista-unificada-impl.md b/docs/plans/pessoas-lista-unificada-impl.md new file mode 100644 index 00000000..abef9292 --- /dev/null +++ b/docs/plans/pessoas-lista-unificada-impl.md @@ -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 })` → `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) diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index fa97bd32..222553ac 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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. { diff --git a/src/app/(campaign)/campanha/(app)/pessoas/loading.tsx b/src/app/(campaign)/campanha/(app)/pessoas/loading.tsx new file mode 100644 index 00000000..7909d507 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/pessoas/loading.tsx @@ -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 ( + +
+ + +
+
+ + + +
+ +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx new file mode 100644 index 00000000..8139a0b3 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx @@ -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> +} + +const PeopleListEmptyState = ({ hasFilters, scoped }: { hasFilters: boolean; scoped: boolean }) => ( + + + + + Nenhuma pessoa neste recorte + + Ajuste a busca e os filtros para ver as pessoas da campanha. + {scoped ? ' Você só vê as pessoas dos seus municípios.' : null} + + + {hasFilters ? ( + + + Limpar busca e filtros + + + ) : null} + +) + +const PeopleMunicipalityCell = ({ + ids, + index, +}: { + ids: readonly number[] + index: ReadonlyMap +}) => { + if (!ids.length) return — + const names = ids + .map((id) => index.get(id)?.name) + .filter((name): name is string => name !== undefined) + if (!names.length) return — + const visible = names.slice(0, 2) + const rest = names.length - visible.length + return ( +
+ {visible.map((name) => ( + + {name} + + ))} + {rest > 0 ? ( + + +{rest} + + ) : null} +
+ ) +} + +const peopleColumns = ({ + municipalityIndex, + canDelete, +}: { + municipalityIndex: ReadonlyMap + canDelete: boolean +}): Array> => [ + { + id: 'name', + label: 'Nome', + mandatory: true, + cell: (row) => ( + + {row.name} + {row.party ? ({row.party}) : null} + + ), + }, + { + id: 'contact', + label: 'Contato', + cellClassName: 'whitespace-normal', + cell: (row) => ( + + {row.phone ? ( + {formatBrazilianPhoneInput(row.phone)} + ) : null} + + {row.email ?? '—'} + + + ), + }, + { + id: 'assessora', + label: 'Assessora', + cellClassName: 'max-w-56 whitespace-normal', + cell: (row) => ( + + ), + }, + { + id: 'lidera', + label: 'Lidera', + cellClassName: 'max-w-56 whitespace-normal', + cell: (row) => ( + + ), + }, + { + id: 'aliada', + label: 'Aliada em', + cellClassName: 'max-w-56 whitespace-normal', + cell: (row) => ( + + ), + }, + { + 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: ( + + Ações + + ), + cellClassName: 'text-right', + cell: (row) => { + const whatsAppHref = whatsAppHrefForPhone(row.phone) + return ( +
+ {row.leadershipID !== null ? ( + + ) : null} + {whatsAppHref ? ( + + ) : ( + + )} + {canDelete ? ( + + ) : null} +
+ ) + }, + }, +] + +const PeopleMobileCards = ({ + rows, + municipalityIndex, + canDelete, + empty, +}: { + rows: readonly PeopleRowViewModel[] + municipalityIndex: ReadonlyMap + canDelete: boolean + empty: ReactNode +}) => ( +
    + {rows.length === 0 ? ( +
  • {empty}
  • + ) : ( + 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 ( +
  • +
    +
    +

    + {row.name} + {row.party ? ({row.party}) : null} +

    +

    + {row.phone + ? formatBrazilianPhoneInput(row.phone) + : row.email + ? row.email + : 'Sem contato registrado'} +

    +
    +
    + {row.leadershipID !== null ? ( + + ) : null} + {whatsAppHref ? ( + + ) : null} + {canDelete ? ( + + ) : null} +
    +
    + {capacities.some((capacity) => capacity.ids.length > 0) ? ( +
    + {capacities.map((capacity) => + capacity.ids.length > 0 ? ( +
    + {capacity.label}: + +
    + ) : null, + )} +
    + ) : null} + {row.assessoradoNames.length > 0 ? ( +

    + Assessorado por {row.assessoradoNames.join(', ')} +

    + ) : null} + {row.city ?

    Base: {row.city}

    : null} +
  • + ) + }) + )} +
+) + +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 ( + + + + + } + /> + + + + } + /> + row.contactID} + empty={ + + } + /> + {listData.rows.length ? ( + buildPeopleListHref(state, page)} + /> + ) : null} + + + + ) +} diff --git a/src/app/(campaign)/campanha/actions/person.ts b/src/app/(campaign)/campanha/actions/person.ts new file mode 100644 index 00000000..6418bbaa --- /dev/null +++ b/src/app/(campaign)/campanha/actions/person.ts @@ -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) +} diff --git a/src/components/campaign/people/DeletePersonButton.tsx b/src/components/campaign/people/DeletePersonButton.tsx new file mode 100644 index 00000000..b5589d3c --- /dev/null +++ b/src/components/campaign/people/DeletePersonButton.tsx @@ -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({ kind: 'idle' }) + const [deleting, setDeleting] = useState(false) + const [deleteError, setDeleteError] = useState('') + const triggerRef = useRef(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 ( + + + + + + + Apagar pessoa + + Esta ação não pode ser desfeita. Serão removidos todos os vínculos de{' '} + {personName} com a campanha. + + + + {manifest.kind === 'loading' ? ( +
+ +
+ ) : null} + + {manifest.kind === 'error' ? ( + + {manifest.message} + + ) : null} + + {manifest.kind === 'ready' ? ( + <> + {manifest.manifest.hasProtectedAccount ? ( + + + {PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE} A ação está bloqueada. + + + ) : ( +
    + {manifest.manifest.leaderships.length > 0 ? ( +
  • + Liderança + {manifest.manifest.leaderships.length > 1 ? 's' : ''} ( + {manifest.manifest.leaderships + .map((leadership) => + leadership.municipalityNames.length + ? `${leadership.name} · ${leadership.municipalityNames.join(' · ')}` + : leadership.name, + ) + .join('; ')} + ) +
  • + ) : null} + {manifest.manifest.stateDeputies.length > 0 ? ( +
  • + Dobradinha{manifest.manifest.stateDeputies.length > 1 ? 's' : ''} ( + {manifest.manifest.stateDeputies + .map((deputy) => + deputy.party ? `${deputy.name} (${deputy.party})` : deputy.name, + ) + .join('; ')} + ) +
  • + ) : null} + {manifest.manifest.pledgeCount > 0 ? ( +
  • + {manifest.manifest.pledgeCount}{' '} + {manifest.manifest.pledgeCount === 1 ? 'voto declarado' : 'votos declarados'} +
  • + ) : null} + {manifest.manifest.inviteCount > 0 ? ( +
  • + {manifest.manifest.inviteCount}{' '} + {manifest.manifest.inviteCount === 1 + ? 'convite pendente' + : 'convites pendentes'} +
  • + ) : null} + {manifest.manifest.supporterCount > 0 ? ( +
  • + {manifest.manifest.supporterCount}{' '} + {manifest.manifest.supporterCount === 1 + ? 'cadastro de apoiador' + : 'cadastros de apoiador'} +
  • + ) : null} + {manifest.manifest.municipalityUpdateCount > 0 ? ( +
  • + {manifest.manifest.municipalityUpdateCount}{' '} + {manifest.manifest.municipalityUpdateCount === 1 + ? 'atualização de município' + : 'atualizações de município'} +
  • + ) : null} + {manifest.manifest.calendarFeedCount > 0 ? ( +
  • + {manifest.manifest.calendarFeedCount}{' '} + {manifest.manifest.calendarFeedCount === 1 + ? 'link de agenda' + : 'links de agenda'} +
  • + ) : null} + {manifest.manifest.accounts.length > 0 ? ( +
  • + {manifest.manifest.accounts.length}{' '} + {manifest.manifest.accounts.length === 1 + ? 'conta de acesso' + : 'contas de acesso'}{' '} + ({manifest.manifest.accounts.map((account) => account.name).join('; ')}) +
  • + ) : null} +
  • + {manifest.manifest.fichaWillBeAnonymized + ? 'A ficha de contato será anonimizada (ainda é referenciada por participações públicas).' + : 'Ficha de contato'} +
  • +
+ )} + {deleteError ? ( + + {deleteError} + + ) : null} + + ) : null} + + + + Cancelar + + + +
+
+ ) +} diff --git a/src/components/campaign/people/PeopleFilters.tsx b/src/components/campaign/people/PeopleFilters.tsx new file mode 100644 index 00000000..b52cef91 --- /dev/null +++ b/src/components/campaign/people/PeopleFilters.tsx @@ -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() + 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 ( + <> +
{ + event.preventDefault() + }} + > + { + runAction(applyPeopleOmniboxSuggestion({ state, suggestionId })) + }} + onCommitQuery={(text) => { + runAction(applyPeopleOmniboxSuggestion({ state, suggestionId: `q:${text}` })) + }} + onRemoveChip={(chipId) => { + runAction(removePeopleOmniboxChip({ state, chipId })) + }} + onClearAll={() => { + runAction(clearPeopleOmnibox()) + }} + trailing={ + <> + {trailing} + + + } + /> + + {/* 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. */} + + + + + ) +} diff --git a/src/components/campaign/people/PeopleListPageChrome.tsx b/src/components/campaign/people/PeopleListPageChrome.tsx new file mode 100644 index 00000000..9bd133df --- /dev/null +++ b/src/components/campaign/people/PeopleListPageChrome.tsx @@ -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 ( + + ) +} diff --git a/src/components/campaign/people/PeopleNavSavedFilters.tsx b/src/components/campaign/people/PeopleNavSavedFilters.tsx new file mode 100644 index 00000000..68b8d631 --- /dev/null +++ b/src/components/campaign/people/PeopleNavSavedFilters.tsx @@ -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('[data-sidebar="menu-sub-button"]') ?? + menuItem?.querySelector('a') + ) + } + const section = row?.closest('[data-saved-filters="overflow"]') + return ( + siblingRow?.querySelector('a') ?? + section?.nextElementSibling?.querySelector('a') ?? + section?.previousElementSibling?.querySelector('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 ( +
+

+ {PEOPLE_SAVED_FILTERS_LABEL} +

+
    + {savedFilters.map((entry) => { + const isActive = entry.href === activeFilter?.href + + return ( +
  • + + {entry.name} + + +
  • + ) + })} +
+
+ ) + } + + return ( + + {savedFilters.map((entry) => { + const isActive = entry.href === activeFilter?.href + + return ( + + + + {entry.name} + + + 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" + > + + + ) + })} + + ) +} diff --git a/src/components/campaign/people/SavePeopleFilterControl.tsx b/src/components/campaign/people/SavePeopleFilterControl.tsx new file mode 100644 index 00000000..bfad975e --- /dev/null +++ b/src/components/campaign/people/SavePeopleFilterControl.tsx @@ -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 + /** + * `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) => { + 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 ( + + + + + + {/* Portaled out of the search form above, so this nests no form. */} +
+ + Nome do filtro + { + 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} + /> + {summary} + + {errorMessage ? ( + + {errorMessage} + + ) : null} + +
+
+
+ ) +} diff --git a/src/components/campaign/shared/CampaignListOmnibox.tsx b/src/components/campaign/shared/CampaignListOmnibox.tsx index 48a285a0..30197206 100644 --- a/src/components/campaign/shared/CampaignListOmnibox.tsx +++ b/src/components/campaign/shared/CampaignListOmnibox.tsx @@ -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} -
+
{trailing} {onClearAll && hasChips ? (
diff --git a/src/components/campaign/shell/CampaignSidebar.tsx b/src/components/campaign/shell/CampaignSidebar.tsx index 02645703..6b9eb7c6 100644 --- a/src/components/campaign/shell/CampaignSidebar.tsx +++ b/src/components/campaign/shell/CampaignSidebar.tsx @@ -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 ? ( ) : null} + {item.href === PEOPLE_NAV_HREF ? ( + + ) : null} ))} diff --git a/src/components/campaign/shell/nav.ts b/src/components/campaign/shell/nav.ts index bb5f3ad0..ba3152ee 100644 --- a/src/components/campaign/shell/nav.ts +++ b/src/components/campaign/shell/nav.ts @@ -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 }, diff --git a/src/lib/campaignColumnVisibility.ts b/src/lib/campaignColumnVisibility.ts index e268794e..2403f09d 100644 --- a/src/lib/campaignColumnVisibility.ts +++ b/src/lib/campaignColumnVisibility.ts @@ -25,6 +25,7 @@ const CAMPAIGN_LIST_IDS = [ 'demandas', 'apoiadores', 'territorios', + 'pessoas', ] as const export type CampaignListId = (typeof CAMPAIGN_LIST_IDS)[number] diff --git a/src/lib/campaignPageChrome.ts b/src/lib/campaignPageChrome.ts index 0d042626..6a30d8af 100644 --- a/src/lib/campaignPageChrome.ts +++ b/src/lib/campaignPageChrome.ts @@ -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), diff --git a/src/lib/schemas/personDelete.ts b/src/lib/schemas/personDelete.ts new file mode 100644 index 00000000..7f461a84 --- /dev/null +++ b/src/lib/schemas/personDelete.ts @@ -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.' diff --git a/src/utilities/campaignSavedFilterStore.ts b/src/utilities/campaignSavedFilterStore.ts new file mode 100644 index 00000000..b37d7e5f --- /dev/null +++ b/src/utilities/campaignSavedFilterStore.ts @@ -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 + 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() + 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 } +} diff --git a/src/utilities/municipality/municipalitySavedFilters.ts b/src/utilities/municipality/municipalitySavedFilters.ts index bf99cda7..472e58e3 100644 --- a/src/utilities/municipality/municipalitySavedFilters.ts +++ b/src/utilities/municipality/municipalitySavedFilters.ts @@ -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 - 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() - 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() } diff --git a/src/utilities/people/peopleData.ts b/src/utilities/people/peopleData.ts new file mode 100644 index 00000000..8ca04724 --- /dev/null +++ b/src/utilities/people/peopleData.ts @@ -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 & { + 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[] => + [...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() + + 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 | 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(state.municipalities ?? []) + const statuses = new Set(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[] => + (['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() + 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 } +} diff --git a/src/utilities/people/peopleLabels.ts b/src/utilities/people/peopleLabels.ts new file mode 100644 index 00000000..e430e00b --- /dev/null +++ b/src/utilities/people/peopleLabels.ts @@ -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 = { + assessora: 'É assessora', + lideranca: 'É liderança', + dobradinha: 'É dobradinha', +} diff --git a/src/utilities/people/peopleListFilters.ts b/src/utilities/people/peopleListFilters.ts new file mode 100644 index 00000000..1a34f9b4 --- /dev/null +++ b/src/utilities/people/peopleListFilters.ts @@ -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 | Readonly>, +): string => { + if (!labels) return `Município #${id}` + if (labels instanceof Map) return labels.get(id) ?? `Município #${id}` + const record = labels as Readonly> + return record[id] ?? `Município #${id}` +} + +export const formatPeopleActiveFiltersSummary = ( + state: PeopleListState, + municipalityLabelsById?: ReadonlyMap | Readonly>, +): 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 +} diff --git a/src/utilities/people/peopleListUrl.ts b/src/utilities/people/peopleListUrl.ts new file mode 100644 index 00000000..1357d86f --- /dev/null +++ b/src/utilities/people/peopleListUrl.ts @@ -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(peopleListParamNames) + +const peopleCapacitySet = new Set(PEOPLE_CAPACITIES) +const supportStatusSet = new Set(leadershipSupportStatuses) + +export const parsePeopleListParams = (params: PeopleListSearchParams): PeopleListState => { + const rawPage = strictDecimalInteger(firstValue(params.page)) + const q = normalizedText(firstValue(params.q)) + const capacities = parseExhaustiveEnumParam(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(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 } +} diff --git a/src/utilities/people/peopleOmnibox.ts b/src/utilities/people/peopleOmnibox.ts new file mode 100644 index 00000000..bc428850 --- /dev/null +++ b/src/utilities/people/peopleOmnibox.ts @@ -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 +}): 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, + 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(), +}) diff --git a/src/utilities/people/peopleSavedFilters.ts b/src/utilities/people/peopleSavedFilters.ts new file mode 100644 index 00000000..b8b8fd64 --- /dev/null +++ b/src/utilities/people/peopleSavedFilters.ts @@ -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 diff --git a/src/utilities/people/personDelete.ts b/src/utilities/people/personDelete.ts new file mode 100644 index 00000000..8faac88b --- /dev/null +++ b/src/utilities/people/personDelete.ts @@ -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 + +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 => { + 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 => { + // 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 => { + 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() + 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 => + 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, + } + }) diff --git a/tests/e2e/campaignPeople.e2e.spec.ts b/tests/e2e/campaignPeople.e2e.spec.ts new file mode 100644 index 00000000..f2e41119 --- /dev/null +++ b/tests/e2e/campaignPeople.e2e.spec.ts @@ -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() + }) +}) diff --git a/tests/int/peopleList.int.spec.ts b/tests/int/peopleList.int.spec.ts new file mode 100644 index 00000000..592d21c9 --- /dev/null +++ b/tests/int/peopleList.int.spec.ts @@ -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) + }) +}) diff --git a/tests/int/personDelete.int.spec.ts b/tests/int/personDelete.int.spec.ts new file mode 100644 index 00000000..edfc412f --- /dev/null +++ b/tests/int/personDelete.int.spec.ts @@ -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 => { + 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() + }) +}) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index ae0ecbe5..9daab9f4 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -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', diff --git a/tests/unit/peopleListUrl.unit.spec.ts b/tests/unit/peopleListUrl.unit.spec.ts new file mode 100644 index 00000000..3a3ae00d --- /dev/null +++ b/tests/unit/peopleListUrl.unit.spec.ts @@ -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' }, + }) + }) +}) diff --git a/tests/unit/peopleMerge.unit.spec.ts b/tests/unit/peopleMerge.unit.spec.ts new file mode 100644 index 00000000..9dfc4383 --- /dev/null +++ b/tests/unit/peopleMerge.unit.spec.ts @@ -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']) + }) +}) diff --git a/tests/unit/peopleSavedFilters.unit.spec.ts b/tests/unit/peopleSavedFilters.unit.spec.ts new file mode 100644 index 00000000..529f9c14 --- /dev/null +++ b/tests/unit/peopleSavedFilters.unit.spec.ts @@ -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() + }) +})