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
18 KiB
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/pessoaslista 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 porContact); 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 reusacreateCampaignInvite/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 decanReadContacts) 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.idcom escopo por capacidade (recomendada); - C) UNION SQL via drizzle.
Recomendação: B — cada fonte já tem loader + where + access pronto (buildLeadershipListWhere, loadStateDeputyListPageData-shape, municipalityIdsByAdvisorIds); a união em memória é uma função pura testável; volume atual (centenas de lideranças, dezenas de dobradinhas/staff) cabe sem paginação no nível de fonte.
Rejeitadas: A porque canReadContacts inclui fichas de apoiadores (v1 exclui) e exige pós-filtro que quebra a semântica de paginação/facets; C porque contorna a camada de access do Payload e duplica o where de cada domínio.
Merge (puro): mergePeopleSources({ leadershipRows, deputyRows, staffAccounts, municipalityIdsBy<Fonte> }) → PeopleRow[] — uma linha por contactID com capacidades preenchidas: leadership? (1, contato UNIQUE), deputy? (1, contato UNIQUE), staff[] (N contas por ficha — dedupe multpapel). Entram pessoas com qualquer papel: liderança, dobradinha ou conta staff com ficha (mesmo sem carteira — gate do humano em 2026-08-09, ver D2). Nome/telefone/e-mail/cidade vêm da ficha (depth-1 populate de contact), partido de stateDeputy.party (única fonte, no máximo uma dobradinha por ficha).
D2 — Access: escopo por capacidade, nunca alargado
- Leadership:
payload.findcomuser+overrideAccess: false→canReadLeadershipaplica o where de carteira do assessor; filtrosq(contact.name contains),supportStatus,municipalities inno where. - Dobradinha:
user+overrideAccess: false→ acesso staff-wide existente; municípios da dobradinha viamunicipalityIdsByStateDeputyIds(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: truejustificado e documentado — precedenteloadAdvisorListPageData(/assessores, mesmo padrão) ecanReadCampaignUserPhone(regra same-municipality advisor); o campocontactdecampaignUsertem field accesscanReadCampaignUserIdentity(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
contactcollection access —canReadContactsnão alcança ficha puramente staff; a ficha do staff entra porque a pessoa aparece por papel staff, mesma regra decanReadCampaignUserPhone).
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):
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).deletePersonemwithPayloadTransaction:reloadUnrestrictedActor+ assert; advisory lockperson-delete:{contactID}(padrãoacquireTextAdvisoryLocks).- 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; cobrecreated_by_idNOT NULL) →supporter(contact = id) →leadership(contact = id) →stateDeputy(contact = id) →campaignUser(contact = id; obeforeDeleteexistente 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_relsjá sãoON DELETE cascade) → fichacontact: delete se nenhum outro join restar (signature/subscription), senão anonimizar (tombstone, precedenteremoveSupporterData).leadership.user,campaign_demand.leadership_id,activity.leadership_idfazem 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ãoRemoveSupporterDataButton.
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 fontestateDeputy.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" (
truncatedNamesLabeldecampaignListUrl.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, vialoadCampaignUserNamesByIds). - Base:
contact.city. - Ações: WhatsApp (
whatsAppHrefForPhoneda ficha — só com telefone), Convite (LeadershipInviteRowActionreusada, só quando há engajamento de liderança), Apagar (só unrestricted,DeletePersonButton). - Column picker: novo
CampaignListId 'pessoas'emCAMPAIGN_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 (
CampaignQuickActionsHostviaCampaignAppScrollChrome) 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; gaterequireCampaignPageActor({ gate: 'staff' })(leader →/campanha/contatos, lockdown); metadata via catálogo de páginas (adicionar entradapessoas).- Sidebar:
PessoasemstaffNavapósDobradinhas; constantePEOPLE_NAV_HREF(padrãoMUNICIPALITY_NAV_HREF) para o submenu de filtros salvos emCampaignSidebar+CampaignBottomNav(overflow); bottom nav primária intacta (Pessoas cai no "Mais" automaticamente).
Componentes / mudanças
people/(novo domínio, espelhandomunicipality/):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 emsrc/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, hooksusePeopleSavedFilters.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 demunicipalitySavedFilters.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
- 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). - Server completo: filtros/facets/paginação +
personDelete(manifest + cascata) + testes de domínio (unit merge/url; int escopo + cascata). - UI: filtros/omnibox, 8 colunas, mobile cards, ações (WhatsApp/Convite/Apagar), column picker, filtros salvos (factory + controle + submenu), sidebar.
- Gates + docs:
pnpm gate:fastna iteração; entrega compnpm push; entrada nodocs/CHANGELOG-AGENTS.md; commit do*-impl.md.
Rabbit holes / Não escopo (engenharia)
- Sistema genérico de filtros salvos (veto FD2); merge de
Contactduplicados (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_relspodem serSET NULLe 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
- 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)
- Invariantes AGENTS/engineering-standards (transação +
req.transactionIDem toda escrita multi-collection;overrideAccessjustificado e documentado; pessoa = join comContact; consent fail-closed intocado; identif. em inglês / copy pt-BR; gatesstaff) - Testes previstos: unit (
peopleListUrlcanonicalização;mergePeopleSourcesdedupe/escopo/partido; factory de storage), int (loadPeopleListPageDataescopo assessor/unrestricted; cascata completadeletePersonincl. passkeys e ficha deletada/anonimizada; manifest; conta protegida bloqueia), e2e smoke (rota + filtro + salvar recorte)