diff --git a/docs/plans/contatos-pagina-da-entidade-contact-impl.md b/docs/plans/contatos-pagina-da-entidade-contact-impl.md
new file mode 100644
index 00000000..3dce6046
--- /dev/null
+++ b/docs/plans/contatos-pagina-da-entidade-contact-impl.md
@@ -0,0 +1,252 @@
+# Impl: C139 — Contatos — página da entidade Contact (lista desktop + cards mobile)
+
+Status: aprovado
+Atualizado em: 2026-08-13
+Issue: #728
+Intenção: docs/plans/contatos-pagina-da-entidade-contact.md
+Appetite restante: herdado (~2–3 dias eng)
+
+## Leitura da intenção
+
+- **Outcome:** `/campanha/contatos` é a página da ficha `Contact` — tabela desktop + cards
+ mobile, omnibox (busca geral por nome/e-mail/qualquer telefone + facets por propriedade e
+ vínculo), ordenação por nome/cidade/estado/e-mail, edição in-place sem moldura,
+ criação (linha desktop / sheet mobile) e apagar com alerta de vínculos. Leader não acessa;
+ `/campanha/pessoas` permanece no ar; nenhuma migration.
+- **O que NÃO negociar:** ficha pura (nada de colunas de capacidade/território/papel);
+ vínculos só como facet; escopo = `canReadContacts` (assessor vê só a carteira, leader
+ lockdown); nome obrigatório e único na criação com aviso amigável preservando o digitado;
+ apagar com manifest de entidades linkadas; leader tool move para `/campanha/meus-contatos`
+ (decisão A travada).
+- **O que reavaliar:**
+ - A hipótese "`updatePersonContact` é o padrão de escopo" precisa de ajuste: a regra C116
+ ("assessor edita só o que tem liderança/dobradinha legível") não cobre fichas que o
+ assessor vê só por apoiador. A página de fichas usa **"edita o que vê" = ficha legível
+ pelo `canReadContacts`** — regra mais simples e coerente com o aceite.
+ - A hipótese "células reusam `CampaignInlineEditableCell`" cobre só texto; gênero/estado
+ são selects e telefones é lista — precisa de duas células novas + param trization leve
+ do componente compartilhado.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ subgraph Rota
+ L1[contatos/page.tsx
gate staff] --> D[loadContactListPageData]
+ D --> F[ContactFilters
omnibox adapter]
+ D --> T[CampaignTable
células permanent]
+ D --> C[MobileCards + EditSheet + CreateSheet]
+ end
+ subgraph Ações
+ A1[updateContactField
blur-save célula] --> TX[withPayloadTransaction]
+ A2[updateContactFull
sheet mobile] --> TX
+ A3[createContactAction
linha nova / FAB] --> TX
+ A4[deleteContactAction
manifest C100] --> TX
+ TX --> N[assertContactNameAvailable
advisory lock contact-name]
+ TX --> S[assertContactEditable
canReadContacts]
+ end
+ subgraph Movida leader
+ M[meus-contatos/page.tsx
= antigo contatos] --> P[LEADER_CONTACTS_HOME
= /campanha/meus-contatos]
+ P --> CH[campaignPageChrome
meusContatos novo]
+ P --> NA[nav leader]
+ end
+```
+
+**Opções consideradas:**
+
+- **A (escolhida): rota nova em `/campanha/contatos` + leader tool movida para
+ `/campanha/meus-contatos`** — decisão de produto travada no gate; toca 6 constantes/catalogos
+ - 3 testes, todos mapeados.
+- **B: leader tool em subrota (`/campanha/contatos/meus`)** — rejeitada no gate (decisão A).
+- **C: dados por `payload.find` único em `contact` com `user` threadado** (sem merge de 3
+ fontes como pessoas) — escolhida: a lista É a ficha; `canReadContacts` já devolve o escopo
+ `{ id: { in } }`; vínculos entram só como facets (3 queries `select: { contact: true }`).
+- **D: reusar `assertPersonContactEditable` (C116)** — rejeitada: exclui fichas de
+ apoiadores do escopo do assessor; a página é da ficha, não de capacidades.
+- **E: `q` com `'phones.value': { like }` no where** — escolhida (Payload suporta subcampo de
+ array); fallback verificado na fase tracer: se o adapter falhar, filtra `q` em memória
+ (a lista é uma fonte só — custo igual).
+- **F: edição do sheet mobile como 7 actions blur** — rejeitada: o sheet é um formulário
+ único; uma action `updateContactFull` (schema completo) é um write atômico.
+- **G: telefones da célula por input/blur com semântica "set primary"** (contrato C116) —
+ rejeitada para a ficha: editar o 2º telefone não deve reordená-lo; a célula commita a
+ **lista inteira** (`field: 'phones'`, contrato C112 que já existe no schema).
+
+**Recomendação:** A+C+E+G — o chassis de listas do Pass 2 e os contratos de ficha já
+existentes cobrem tudo; o trabalho novo é a camada de domínio de contatos (URL state,
+loader, ações) + duas células novas + os dois sheets + a movida do leader tool.
+
+### Componentes / mudanças
+
+**Movida do leader tool (fase 1):**
+
+- **`LEADER_CONTACTS_HOME`** (`src/lib/campaignPaths.ts`): `'/campanha/meus-contatos'`.
+- **`src/app/(campaign)/campanha/(app)/contatos/page.tsx`** → `meus-contatos/page.tsx`
+ (conteúdo igual; metadata `meusContatos`).
+- **`campaignPageChrome.ts`**: novo `meusContatos` no catálogo (copy atual do leader);
+ `contatos` ganha copy de staff ('Fichas da campanha — atualize dados e fale com as
+ pessoas', do draft); regra de path `LEADER_CONTACTS_HOME` → `meusContatos` + regra nova
+ `/campanha/contatos` → `contatos`.
+- **`nav.ts`**: leaderNav usa a constante (automático); staffNav ganha entrada 'Contatos'.
+- **`campaignPageActor.ts`**: denyRedirect do leader já usa a constante (automático).
+- **`campaignQuickActionMount.ts`**: `isLeaderContactsPath` usa a constante (automático);
+ `shouldMountQuickActionsFab` passa a retornar `false` para `/campanha/contatos` (staff —
+ o FAB de criação da página substitui o de ações rápidas; FAB de IA permanece, é montado
+ à parte).
+- **`campaignQuickActionRegistry.ts`**: nada muda (rota leader ≠ contatos staff).
+- **`campaignHomeActions.ts` / `campaignNavigationUrls.ts`**: usam a constante — o destino
+ `leaderContacts` do AI continua válido no caminho novo.
+
+**Domínio de contatos:**
+
+- **`src/utilities/contacts/contactListUrl.ts`** (puro, espelho de `peopleListUrl`):
+ state `{ page, q?, genders?, states?, cities?, ausencias?, vinculos?, sort?, dir? }`;
+ params `q|gender|state|city|ausencia|vinculo|sort|dir|page`; parse/canonicalize via
+ `resolveListUrl`; `ausencia` e `vinculo` com parse **sem** `parseExhaustiveEnumParam`
+ (selecionar todos ≠ nenhum — mesmo racional do umbrella C125); sort keys
+ `name|cidade|estado|email` (defaults asc); `contactPageSize = 25`; `buildContactListWhere`
+ (q por `or: name like / email like / 'phones.value' like`; demais filtros em memória);
+ labels: gênero (4 valores), UFs (`CitiesByState`), ausências (`sem_telefone|sem_email`),
+ vínculos (`liderancas|dobradinhas|assessores|equipe`).
+- **`src/utilities/contacts/contactListData.ts`** (server-only, espelho de `peopleData`):
+ `loadContactListPageData(payload, user, state)` — 1 query `contact` (depth 0, limit 0,
+ `user` + `overrideAccess: false` — o escopo é o `canReadContacts` da própria collection)
+ - 3 queries de vínculo (`leadership`/`stateDeputy` com `user` threadado; `campaignUser`
+ com `overrideAccess: true` justificado — mesmo precedente da fonte staff de pessoas —
+ merge escopado pelo conjunto de contatos legíveis); facets do conjunto escopado com
+ valores selecionados unidos (padrão `peopleFilterFacetsFromRows`); filtro/sort em memória
+ (nulls last, tie-break nome→id); paginação em memória.
+- **`src/utilities/contacts/contactNameInvariant.ts`**: `assertContactNameAvailable`
+ (padrão `assertStateDeputyNameAvailable`): normalização = trim + colapso de espaços +
+ minúsculas; lock `contact-name:{normalized}`; query `name: { like: normalized }` (ILIKE
+ contains) + conferência exata em memória (cobre fichas com espaços duplicados); throw
+ `CONTACT_NAME_CONFLICT_MESSAGE` ('Já existe um contato com este nome — confira a lista
+ antes de salvar.'). Vale para criação E renomeação manual (rabbit hole da intenção).
+- **Migration:** nenhuma (schema intocado — valores de enum inalterados).
+
+**Ações (`src/app/(campaign)/campanha/actions/contact.ts`, 'use server):**
+
+- **`updateContactField`** — blur-save das células desktop: schema = `contactFieldUpdateSchema`
+ estendido com branches `gender|state|city|postalCode` (aditivo, o switch de person.ts
+ ignora branches novas); `withPayloadTransaction`; `reloadStaffActor`; `assertContactEditable`
+ (unrestricted passa; advisor: ficha legível com `user` + `overrideAccess: false` — "edita o
+ que vê"); branch `name` → `assertContactNameAvailable`; write `overrideAccess: true`
+ (justificado pelo gate + escopo — mesmo precedente do `updatePersonContactRecord`);
+ revalida `/campanha/contatos` + pessoas/liderancas/dobradinhas.
+- **`updateContactFull`** — sheet mobile: schema completo da ficha (zod novo
+ `contactFullUpdateSchema`), mesmos gate/escopo/invariante; write atômico.
+- **`createContactAction`** — linha desktop / sheet mobile: `contactCreateSchema` (name
+ obrigatório [2..120], email opcional, phones array dedupe, gender opcional, `state`
+ obrigatório — a collection exige, city/CEP opcionais); `reloadStaffActor` (criação =
+ equipe, incl. assessor — a ficha sem vínculo nasce invisível para o assessor, comportamento
+ aceito, ver rabbit holes); `assertContactNameAvailable`; `payload.create` com
+ `overrideAccess: true` justificado (access.create é admin-only; o gate staff + invariante
+ são a autorização).
+- **Apagar** — reusa o contrato C100: `getContactDeleteManifestAction` / `deleteContactAction`
+ via `loadPersonDeleteManifest` / `deletePersonRecord` com `reloadUnrestrictedActor`
+ (coordenador/candidato — botão só para unrestricted, precedente `canDelete` de pessoas).
+- **`src/app/(campaign)/campanha/(app)/contatos/formActions.ts`**: ladder
+ `runCampaignFormAction` + safeMessages (nome conflito, fora de escopo, telefone inválido,
+ duplicado).
+
+**Access / Consent:** `canReadContacts` (leitura, já existe), `canManageContacts` não muda;
+nenhum Consent novo (dados internos de ficha). Locks: `contact-name:{normalized}`.
+
+**UI (Impeccable C — rota nova, shape → craft → critique → polish):**
+
+- **Reuso:** `CampaignListOmnibox` (chips no input), `CampaignSortableHead` (C117),
+ `CampaignTable` + `CampaignColumnPicker`, `CampaignListPagination`, `CampaignListFooter`,
+ `CampaignListSheetProvider`, `useCampaignListFilterNavigation`, `CampaignTransitionAnchor`.
+- **`CampaignInlineEditableCell`** (compartilhado, mudança aditiva): `placeholder?` prop
+ (default '—') e `field` union ganha `'city' | 'postalCode'` — as 4 células de texto
+ (nome/e-mail/cidade/CEP) usam `permanent` com placeholders 'Sem e-mail'/'Sem telefone'
+ nos vazios (placeholder vem da célula: email/city/CEP sem valor → label pt-BR).
+- **`src/components/campaign/contacts/ContactSelectCell.tsx`** (novo, client): select nativo
+ sem moldura para gênero (4 valores, label do collection alterado) e estado (27 UFs);
+ onChange commita via `formAction` (`field: gender|state`); erro → reverte + toast.
+- **`ContactPhonesCell.tsx`** (novo, client): inputs empilhados sem moldura (máscara
+ `formatBrazilianPhoneInput`); blur/Enter de qualquer linha commita a **lista inteira**
+ (`field: 'phones'`, empties descartados antes do schema); "+" adiciona linha; Escape
+ descarta o draft.
+- **`ContactCreateRow.tsx`** (novo, client): linha vazia no topo da tabela (fundo destacado,
+ inputs sem moldura), Salvar (inativo sem nome) / Descartar; conflito → mensagem na linha
+ mantendo o digitado; `ContactCreateButton` no trailing do omnibox (desktop) + FAB móvel
+ (criação; `md:hidden`, mesma posição do quick-actions FAB) — estado compartilhado por um
+ provider client simples.
+- **Mobile:** `ContactMobileCard` (nome, secundária = telefone ?? e-mail, chip gênero +
+ "Cidade · Estado", ações WhatsApp/e-mail/Apagar) + `ContactEditSheet` (todos os campos
+ full-bleed com divisórias, `PhonesFieldEditor` form mode para telefones, "Apagar contato"
+ vermelho, Cancelar/Salvar via footer custom) + `ContactCreateSheet` (mesmo form vazio).
+- **`ContactDeleteDialog.tsx`** (novo, client): alerta com manifest do `loadPersonDeleteManifest`
+ (lideranças com municípios, dobradinhas, pledges, invites, apoiadores, atualizações,
+ contas, ficha anonimizada) — reusa o tipo e a enumeração do C100.
+- **Colunas:** Nome, Gênero, Telefone, E-mail, Cidade, Estado, CEP, Ações (ações = coluna
+ obrigatória). `CAMPAIGN_LIST_IDS` ganha `'contatos'`; default hidden `['postalCode']`.
+- **Chrome/copy:** catálogo `contatos` (staff) + `meusContatos` (leader).
+
+### Dados → forma
+
+- Forma escolhida: valores da ficha direto nas células/inputs (edição in-place); facets como
+ seeds estáticos + conjuntos data-driven (cidades distintas do escopo); sem agregados —
+ nenhum KPI na superfície (decisão de produto "Vou apresentar dados? Não").
+- Rejeitada: COUNT(\*) FILTER SQL (sem KPI que justifique), agregado por página.
+
+## Fases verificáveis
+
+1. **Movida do leader tool** — constante + rota + chrome + nav + FAB mount; atualiza os
+ testes que pinam o path (`leaderContactsQuickActions`, `campaignHomeActions`,
+ `campaignNavigationUrls`, `campaignPageChrome`, e2e `campaignPeople` lockdown). Gate
+ unit + e2e da área leader verdes.
+2. **Tracer do domínio** — `contactListUrl` + `contactListData` + verificação do
+ `'phones.value'` no adapter Postgres (fallback em memória se falhar) + tests unit puros
+ (parse/canonical/where/filtro/sort/facets). Gate unit verdes.
+3. **Ações** — `contactNameInvariant`, schemas, `actions/contact.ts`, `formActions.ts`,
+ `Contact` gender label ('Outro' → 'Não binário', sem migration — verificar grep de
+ testes que pinam a label). Gate unit verdes.
+4. **UI desktop** — células (param trization + 2 novas), coluna de ações, create row,
+ omnibox adapter, column picker. Shape → craft → critique → polish (Impeccable C).
+5. **UI mobile** — cards, sheets (edição/criação), FAB, apagar no card/sheet.
+6. **Gates + e2e** — `tests/e2e/campaignContacts.e2e.spec.ts` novo (criação com conflito,
+ edição blur, facet, q por telefone, sort, cards/sheet mobile, apagar com manifest,
+ escopo assessor) + `pnpm gate:fast` + full local.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- **Não** empurrar filtros de vínculo para o where SQL (facet de joins exige o catálogo
+ por ficha — 3 queries limit 0 são o padrão de pessoas).
+- **Não** criar aggregate SQL de KPI (não há KPI no aceite).
+- **Não** dedupe retroativo de nomes nem unicidade no import CSV (rabbit hole de produto
+ confirmado).
+- **Não** edição em massa, filtros salvos (B18 fica disponível), coluna de vínculos.
+- **Não** alterar o FAB de IA, a página de Pessoas, listas especializadas.
+- Ficha criada por assessor sem vínculo fica invisível para ele próprio (escopo
+ `canReadContacts` exige vínculo) — comportamento aceito no v1, anotado para produto.
+
+## Riscos e mitigação
+
+- **`'phones.value'` no where do adapter** — testar na fase 2; fallback: `q` em memória
+ (uma fonte só, custo igual ao de pessoas).
+- **`canReadContacts` com `id: in` grande (coordinator = `true`, advisor/leader = lista)** —
+ mesmo custo do acesso atual da collection; sem mudança de contrato.
+- **Write `overrideAccess: true` em `contact`** — sempre precedido do gate staff +
+ escopo/invariante na mesma transação; comentário de justificativa no local (padrão do
+ repo).
+- **`CampaignInlineEditableCell` compartilhado** — mudança aditiva (prop opcional + union);
+ nenhum usuário atual quebra; `pnpm test` da suíte de pessoas cobre.
+- **Tabela com ~7 inputs por linha × 25 linhas** — precedente de pessoas (células permanent);
+ inputs sem estado de edição são baratos; verificar com e2e que a página não degrada.
+- **Label de gênero 'Outro' → 'Não binário'** — valores de enum inalterados (sem
+ migration); grep de testes que pinam a label antes.
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção coberto (ficha pura, facets, edição onde se vê,
+ criação com nome único, apagar com manifest, leader fora, Pessoas no ar, sem migration)
+- [ ] Invariantes AGENTS/engineering-standards (gate staff por página, tx + locks nas
+ escritas multi-etapa, `user` threadado na leitura, overrideAccess justificado)
+- [ ] Testes de domínio previstos (unit: URL state/where/invariante de nome; int/e2e:
+ escopo assessor, lockdown leader no caminho novo, conflito de nome, apagar com manifest)
+- [ ] `pnpm gate:fast`, `pnpm build` local, Aikido sem novos findings nos arquivos editados
+
+Self-score decision-quality: 4/5 — decisões mapeadas a precedentes verificados no código;
+único ponto de validação em runtime é o subcampo de array no where (fallback definido).
diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs
index 4dda1e14..d5a6ff3a 100644
--- a/scripts/lib/e2e-affected-manifest.mjs
+++ b/scripts/lib/e2e-affected-manifest.mjs
@@ -164,6 +164,7 @@ export const E2E_AFFECTED_MANIFEST = [
`${CAMPAIGN_APP}/apoiadores`,
`${CAMPAIGN_APP}/assessores`,
`${CAMPAIGN_APP}/atualizacoes`,
+ `${CAMPAIGN_APP}/meus-contatos`,
`${CAMPAIGN_APP}/contatos`,
`${CAMPAIGN_APP}/demandas`,
`${CAMPAIGN_APP}/dobradinhas`,
diff --git a/src/app/(campaign)/campanha/(app)/contatos/page.tsx b/src/app/(campaign)/campanha/(app)/meus-contatos/page.tsx
similarity index 89%
rename from src/app/(campaign)/campanha/(app)/contatos/page.tsx
rename to src/app/(campaign)/campanha/(app)/meus-contatos/page.tsx
index 99deaba5..b05e4939 100644
--- a/src/app/(campaign)/campanha/(app)/contatos/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/meus-contatos/page.tsx
@@ -10,9 +10,9 @@ import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData'
export const dynamic = 'force-dynamic'
-export const metadata = campaignPageMetadataFromCatalog('contatos')
+export const metadata = campaignPageMetadataFromCatalog('meusContatos')
-export default async function CampaignContatosPage() {
+export default async function CampaignMeusContatosPage() {
const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()])
if (!isCampaignLeader(user)) {
diff --git a/src/components/campaign/shell/nav.ts b/src/components/campaign/shell/nav.ts
index ba3152ee..4324d568 100644
--- a/src/components/campaign/shell/nav.ts
+++ b/src/components/campaign/shell/nav.ts
@@ -3,6 +3,7 @@ import {
BookOpenIcon,
CalendarDaysIcon,
ContactIcon,
+ ContactRoundIcon,
EllipsisVerticalIcon,
HandshakeIcon,
HomeIcon,
@@ -19,6 +20,7 @@ import {
import {
CAMPAIGN_AGENDA_HOME,
+ CAMPAIGN_CONTACTS_HOME,
CAMPAIGN_UPDATES_HREF,
LEADER_CONTACTS_HOME,
} from '@/lib/campaignPaths'
@@ -54,6 +56,7 @@ const staffNav: CampaignNavItem[] = [
{ title: 'Organizações', href: '/campanha/organizacoes', icon: LandmarkIcon },
{ title: 'Dobradinhas', href: '/campanha/dobradinhas', icon: Users2Icon },
{ title: 'Pessoas', href: PEOPLE_NAV_HREF, icon: ContactIcon },
+ { title: 'Contatos', href: CAMPAIGN_CONTACTS_HOME, icon: ContactRoundIcon },
{ 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/campaignPageChrome.ts b/src/lib/campaignPageChrome.ts
index 238d1b83..6c7fb1db 100644
--- a/src/lib/campaignPageChrome.ts
+++ b/src/lib/campaignPageChrome.ts
@@ -1,7 +1,12 @@
import type { LucideIcon } from 'lucide-react'
import type { Metadata } from 'next'
-import { CAMPAIGN_HOME, CAMPAIGN_UPDATES_HREF, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
+import {
+ CAMPAIGN_CONTACTS_HOME,
+ CAMPAIGN_HOME,
+ CAMPAIGN_UPDATES_HREF,
+ LEADER_CONTACTS_HOME,
+} from '@/lib/campaignPaths'
import { normalizePathname } from '@/lib/campaignQuickActionPaths'
import type { CampaignUser } from '@/payload-types'
@@ -98,6 +103,10 @@ export const campaignPageChromeCatalog = {
},
contatos: {
title: 'Contatos',
+ subtitle: 'Fichas da campanha — atualize dados e fale com as pessoas.',
+ },
+ meusContatos: {
+ title: 'Meus contatos',
subtitle: 'Cadastre apoiadores pelo celular. Só você vê os contatos que criou aqui.',
},
perfil: {
@@ -251,9 +260,13 @@ const pathRules: PathRule[] = [
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atualizacoes),
},
{
- match: (pathname) => pathname === LEADER_CONTACTS_HOME,
+ match: (pathname) => pathname === CAMPAIGN_CONTACTS_HOME,
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.contatos),
},
+ {
+ match: (pathname) => pathname === LEADER_CONTACTS_HOME,
+ resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.meusContatos),
+ },
{
match: (pathname) => pathname === '/campanha/perfil',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.perfil),
diff --git a/src/lib/campaignPaths.ts b/src/lib/campaignPaths.ts
index 96983b68..d38a2bcc 100644
--- a/src/lib/campaignPaths.ts
+++ b/src/lib/campaignPaths.ts
@@ -11,7 +11,12 @@ export const CAMPAIGN_SUPPORTERS_HOME = '/campanha/apoiadores' as const
export const CAMPAIGN_AGENDA_HOME = '/campanha/agenda' as const
-export const LEADER_CONTACTS_HOME = '/campanha/contatos' as const
+/** Staff page for the `Contact` ficha (C139). */
+export const CAMPAIGN_CONTACTS_HOME = '/campanha/contatos' as const
+
+/** Leader's own supporter contact tool (B43) — moved here by C139 so staff
+ * and leader no longer share the `/campanha/contatos` route. */
+export const LEADER_CONTACTS_HOME = '/campanha/meus-contatos' as const
export const CAMPAIGN_PROFILE_HOME = '/campanha/perfil' as const
diff --git a/src/lib/campaignQuickActionMount.ts b/src/lib/campaignQuickActionMount.ts
index cf56febc..f64a11b4 100644
--- a/src/lib/campaignQuickActionMount.ts
+++ b/src/lib/campaignQuickActionMount.ts
@@ -1,6 +1,6 @@
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import { isAdvisorsPath } from '@/lib/campaignAdvisorQuickActions'
-import { CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
+import { CAMPAIGN_CONTACTS_HOME, CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { isActivityTourComposerPath, isListPath } from '@/lib/campaignQuickActionPaths'
import {
isStaffCampaignRole,
@@ -17,6 +17,11 @@ export const isCampaignActionsPath = (pathname: string): boolean =>
export const isLeaderContactsPath = (pathname: string): boolean =>
pathname === LEADER_CONTACTS_HOME || pathname.startsWith(`${LEADER_CONTACTS_HOME}/`)
+/** C139 — the staff contacts page owns a create FAB of its own, so the
+ * quick-actions drawer stays out of it. */
+export const isContactsPath = (pathname: string): boolean =>
+ pathname === CAMPAIGN_CONTACTS_HOME || pathname.startsWith(`${CAMPAIGN_CONTACTS_HOME}/`)
+
/**
* Whether the quick-actions FAB should mount on this navigation (all viewports).
*/
@@ -26,6 +31,7 @@ export const shouldMountQuickActionsFab = (pathname: string, role: CampaignRole)
// B84: E13 compositor already exposes primary CTAs — skip competing drawer chrome.
if (isActivityTourComposerPath(pathname)) return false
if (role === 'leader') return isLeaderContactsPath(pathname)
+ if (isContactsPath(pathname)) return false
if (isAdvisorsPath(pathname)) return isUnrestrictedCampaignRole(role)
return isStaffCampaignRole(role)
}
diff --git a/src/lib/leaderContactsQuickActions.ts b/src/lib/leaderContactsQuickActions.ts
index 0d6eaf0f..0c8a25f4 100644
--- a/src/lib/leaderContactsQuickActions.ts
+++ b/src/lib/leaderContactsQuickActions.ts
@@ -7,7 +7,7 @@ import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContex
import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes'
import type { CampaignRole } from '@/lib/campaignRoles'
-/** In-page anchors on `/campanha/contatos` — hash navigation from the B89 drawer. */
+/** In-page anchors on `/campanha/meus-contatos` — hash navigation from the B89 drawer. */
export const LEADER_CONTACT_FORM_HASH = '#leader-contact-form' as const
export const LEADER_CONTACTS_LIST_HASH = '#leader-contacts-list' as const
diff --git a/src/utilities/campaignPageActor.ts b/src/utilities/campaignPageActor.ts
index 503ac149..91fa22bd 100644
--- a/src/utilities/campaignPageActor.ts
+++ b/src/utilities/campaignPageActor.ts
@@ -22,9 +22,9 @@ export const CAMPAIGN_STAFF_QUADRO_PATH = '/campanha/quadro'
* `return null` — a blank screen — instead of redirecting to login). Gates:
*
* - (none) → any authenticated campaign user;
- * - 'staff' → non-staff (leader) goes to `/campanha/contatos` (B43);
+ * - 'staff' → non-staff (leader) goes to `/campanha/meus-contatos` (B43, C139);
* - 'unrestricted' → non-coordinator/candidate goes to `/campanha`;
- * - 'noLeader' → leader goes to `/campanha/contatos` (B43).
+ * - 'noLeader' → leader goes to `/campanha/meus-contatos` (B43, C139).
*
* A custom `redirectTo` overrides the gate's default target. The convention
* guard in `codebaseConventions.unit.spec.ts` fails the build on a
diff --git a/src/utilities/contacts/contactListData.ts b/src/utilities/contacts/contactListData.ts
new file mode 100644
index 00000000..fe8a0c1a
--- /dev/null
+++ b/src/utilities/contacts/contactListData.ts
@@ -0,0 +1,312 @@
+import 'server-only'
+
+import type { Payload } from 'payload'
+
+import { relationshipId } from '@/lib/relationship'
+import { leadershipGenders } from '@/lib/schemas/leadership'
+import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types'
+import { isCampaignStaff } from '@/utilities/campaignAccess'
+import {
+ buildContactListWhere,
+ contactPageSize,
+ resolveContactListSort,
+ type ContactGender,
+ type ContactListState,
+ type ContactStateKey,
+ type ContactVinculo,
+} from '@/utilities/contacts/contactListUrl'
+
+/**
+ * The contacts page data (C139): ONE source — the `Contact` ficha itself.
+ * Unlike people, there is no merge: the list IS the ficha, and the actor
+ * scope is enforced by the `contact` collection's own `canReadContacts`
+ * access (read with `user` threaded + `overrideAccess: false`).
+ *
+ * Vínculos (liderança, dobradinha, assessores, equipe) enter ONLY as facets:
+ * three small queries resolve which readable contacts carry which link, so
+ * the omnibox can filter "fichas com liderança" etc. The staff source
+ * (`campaignUser`) reads with a justified `overrideAccess` — staff identity
+ * is gated by a different collection rule — and the merge never widens the
+ * surface: a contact id outside the readable set is simply not a row here.
+ */
+
+type ContactVinculosByID = ReadonlyMap
+
+export type ContactRowViewModel = {
+ contactID: number
+ name: string
+ email: string | null
+ /** Stored order = priority (first is primary) — C112 contract. */
+ phones: string[]
+ gender: ContactGender | null
+ state: ContactStateKey | null
+ city: string | null
+ postalCode: string | null
+ /** Which vínculos this ficha carries (facet + filter, never a column). */
+ vinculos: ContactVinculo[]
+}
+
+export type ContactListFilterFacets = {
+ genders: ContactGender[]
+ states: ContactStateKey[]
+ cities: string[]
+ vinculos: ContactVinculo[]
+}
+
+const EMPTY_FACETS: ContactListFilterFacets = { genders: [], states: [], cities: [], vinculos: [] }
+
+const isContactGender = (value: unknown): value is ContactGender =>
+ typeof value === 'string' && leadershipGenders.some((gender) => gender === value)
+
+const isStateKey = (value: unknown): value is ContactStateKey =>
+ typeof value === 'string' && /^[A-Z]{2}$/.test(value)
+
+export const toContactRowViewModel = (doc: Contact): ContactRowViewModel => ({
+ contactID: doc.id,
+ name: doc.name,
+ email: doc.email ?? null,
+ phones: Array.isArray(doc.phones)
+ ? doc.phones.map((entry) => entry.value).filter((value): value is string => !!value)
+ : [],
+ gender: isContactGender(doc.gender) ? doc.gender : null,
+ state: isStateKey(doc.state) ? doc.state : null,
+ city: doc.city ?? null,
+ postalCode: doc.postalCode ?? null,
+ vinculos: [],
+})
+
+/**
+ * Resolves the vínculo map for the scoped contact set. Each source reads with
+ * its own collection access (`user` threaded); the campaignUser source needs
+ * the justified admin bypass (staff identity lives behind
+ * `canReadCampaignUserIdentity`, same precedent as the people staff source) —
+ * the result is intersected with the readable contact ids, so a link never
+ * widens the list.
+ */
+export const loadContactVinculos = async (
+ payload: Payload,
+ user: CampaignUser,
+ readableContactIDs: ReadonlySet,
+): Promise => {
+ const byID = new Map>()
+ const add = (contactID: number, vinculo: ContactVinculo) => {
+ if (!readableContactIDs.has(contactID)) return
+ let set = byID.get(contactID)
+ if (!set) {
+ set = new Set()
+ byID.set(contactID, set)
+ }
+ set.add(vinculo)
+ }
+
+ const [leadershipResult, deputyResult, staffResult] = await Promise.all([
+ payload.find({
+ collection: 'leadership',
+ depth: 0,
+ limit: 0,
+ pagination: false,
+ select: { contact: true },
+ user,
+ overrideAccess: false,
+ }),
+ payload.find({
+ collection: 'stateDeputy',
+ depth: 0,
+ limit: 0,
+ pagination: false,
+ select: { contact: true },
+ user,
+ overrideAccess: false,
+ }),
+ payload.find({
+ collection: 'campaignUser',
+ depth: 0,
+ limit: 0,
+ pagination: false,
+ select: { contact: true },
+ // Intentional bypass (impl plan D): the `contact` field is
+ // identity-gated and the actor scope is enforced by the readable-set
+ // intersection below — same rationale as the people staff source.
+ overrideAccess: true,
+ }),
+ ])
+
+ for (const doc of leadershipResult.docs as Leadership[]) {
+ const id = relationshipId(doc.contact)
+ if (id !== null) add(id, 'liderancas')
+ }
+ for (const doc of deputyResult.docs as StateDeputy[]) {
+ const id = relationshipId(doc.contact)
+ if (id !== null) add(id, 'dobradinhas')
+ }
+ for (const doc of staffResult.docs as CampaignUser[]) {
+ const id = relationshipId(doc.contact)
+ if (id !== null) add(id, 'equipe')
+ }
+
+ const result = new Map()
+ for (const [id, set] of byID) {
+ result.set(
+ id,
+ [...set].sort((left, right) => left.localeCompare(right, 'pt-BR')),
+ )
+ }
+ return result
+}
+
+const vinculoSet = (row: ContactRowViewModel): Set => new Set(row.vinculos)
+
+export const filterContactRows = (
+ rows: readonly ContactRowViewModel[],
+ state: ContactListState,
+): ContactRowViewModel[] =>
+ rows.filter((row) => {
+ if (state.genders?.length) {
+ if (row.gender === null || !state.genders.includes(row.gender)) return false
+ }
+ if (state.states?.length) {
+ if (row.state === null || !state.states.includes(row.state)) return false
+ }
+ if (state.cities?.length) {
+ const wanted = new Set(state.cities.map((city) => city.toLowerCase()))
+ if (row.city === null || !wanted.has(row.city.toLowerCase())) return false
+ }
+ if (state.ausencias?.length) {
+ const wanted = new Set(state.ausencias)
+ const semTelefone = row.phones.length === 0
+ const semEmail = row.email === null
+ const matches =
+ (wanted.has('sem_telefone') && semTelefone) || (wanted.has('sem_email') && semEmail)
+ if (!matches) return false
+ }
+ if (state.vinculos?.length) {
+ const links = vinculoSet(row)
+ if (!state.vinculos.some((vinculo) => links.has(vinculo))) return false
+ }
+ return true
+ })
+
+export const contactFilterFacetsFromRows = (
+ rows: readonly ContactRowViewModel[],
+ state: ContactListState,
+): ContactListFilterFacets => {
+ const genders = new Set(state.genders ?? [])
+ const states = new Set(state.states ?? [])
+ // City is free text: dedupe case-insensitively but keep the first-seen
+ // casing for display (data-driven facet, municipality precedent).
+ const cities = new Map()
+ for (const city of state.cities ?? []) {
+ cities.set(city.toLowerCase(), city)
+ }
+ const vinculos = new Set(state.vinculos ?? [])
+ for (const row of rows) {
+ if (row.gender !== null) genders.add(row.gender)
+ if (row.state !== null) states.add(row.state)
+ if (row.city !== null && !cities.has(row.city.toLowerCase())) {
+ cities.set(row.city.toLowerCase(), row.city)
+ }
+ for (const vinculo of row.vinculos) vinculos.add(vinculo)
+ }
+ return {
+ genders: [...genders].sort((left, right) => left.localeCompare(right, 'pt-BR')),
+ states: [...states].sort((left, right) => left.localeCompare(right, 'pt-BR')),
+ cities: [...cities.values()].sort((left, right) => left.localeCompare(right, 'pt-BR')),
+ vinculos: [...vinculos].sort((left, right) => left.localeCompare(right, 'pt-BR')),
+ }
+}
+
+/**
+ * C139 — global sort over the FILTERED set (never the page), applied before
+ * pagination. Text columns by value; nulls ("Sem…") always land last (B15
+ * precedent), ties break by name, then contact id — the same order the list
+ * opened with.
+ */
+export const sortContactRows = (
+ rows: readonly ContactRowViewModel[],
+ sort: ContactListState['sort'],
+ dir: ContactListState['dir'],
+): ContactRowViewModel[] => {
+ const direction = dir === 'asc' ? 1 : -1
+ const sortValue = (row: ContactRowViewModel): string | null => {
+ switch (sort) {
+ case 'cidade':
+ return row.city
+ case 'estado':
+ return row.state
+ case 'email':
+ return row.email
+ case 'name':
+ default:
+ return row.name
+ }
+ }
+ const byNameThenId = (left: ContactRowViewModel, right: ContactRowViewModel): number =>
+ left.name.localeCompare(right.name, 'pt-BR') || left.contactID - right.contactID
+
+ return [...rows].sort((left, right) => {
+ const leftValue = sortValue(left)
+ const rightValue = sortValue(right)
+ if (leftValue === null && rightValue === null) return byNameThenId(left, right)
+ if (leftValue === null) return 1
+ if (rightValue === null) return -1
+ return leftValue.localeCompare(rightValue, 'pt-BR') * direction || byNameThenId(left, right)
+ })
+}
+
+/**
+ * The page data for `/campanha/contatos`. `q` runs at the source level; the
+ * property/vínculo filters, the facets and the pagination run in memory over
+ * the scoped rows (one source — no merge cost).
+ */
+export const loadContactListPageData = async (
+ payload: Payload,
+ user: CampaignUser,
+ state: ContactListState,
+): Promise<{
+ rows: ContactRowViewModel[]
+ totalDocs: number
+ totalPages: number
+ filterFacets: ContactListFilterFacets
+}> => {
+ if (!isCampaignStaff(user)) {
+ return { rows: [], totalDocs: 0, totalPages: 0, filterFacets: EMPTY_FACETS }
+ }
+
+ const contactResult = await payload.find({
+ collection: 'contact',
+ where: buildContactListWhere(state),
+ depth: 0,
+ limit: 0,
+ pagination: false,
+ select: {
+ name: true,
+ email: true,
+ phones: true,
+ gender: true,
+ state: true,
+ city: true,
+ postalCode: true,
+ },
+ user,
+ overrideAccess: false,
+ })
+
+ const docs = contactResult.docs as Contact[]
+ const rows = docs.map(toContactRowViewModel)
+ const readableContactIDs = new Set(rows.map((row) => row.contactID))
+ const vinculosByID = await loadContactVinculos(payload, user, readableContactIDs)
+ for (const row of rows) {
+ row.vinculos = vinculosByID.get(row.contactID) ?? []
+ }
+
+ const filterFacets = contactFilterFacetsFromRows(rows, state)
+ const filtered = filterContactRows(rows, state)
+ const { sort, dir } = resolveContactListSort(state)
+ const sorted = sortContactRows(filtered, sort, dir)
+
+ const totalDocs = sorted.length
+ const totalPages = Math.ceil(totalDocs / contactPageSize)
+ const pageRows = sorted.slice((state.page - 1) * contactPageSize, state.page * contactPageSize)
+
+ return { rows: pageRows, totalDocs, totalPages, filterFacets }
+}
diff --git a/src/utilities/contacts/contactListUrl.ts b/src/utilities/contacts/contactListUrl.ts
new file mode 100644
index 00000000..17a8f882
--- /dev/null
+++ b/src/utilities/contacts/contactListUrl.ts
@@ -0,0 +1,263 @@
+/**
+ * Contacts list URL contract (C139): state, param parse/canonicalize,
+ * source-level Payload `where` and hrefs. Own module, same shape as
+ * `peopleListUrl` / `leadershipListUrl`.
+ *
+ * The `where` only covers what a single `contact` query can express (`q`).
+ * Every other filter (gênero/estado/cidade/ausência/vínculo) is applied in
+ * memory over the scoped rows (`contactListData.ts`) — reverse relations and
+ * absence predicates have no SQL shape here.
+ */
+import type { Where } from 'payload'
+
+import { CitiesByState } from '@/lib/cities'
+import { leadershipGenders } from '@/lib/schemas/leadership'
+import {
+ allParamValues,
+ buildListHref,
+ createSortToggleHref,
+ firstValue,
+ normalizedText,
+ resolveListUrl,
+ strictDecimalInteger,
+ type RawSearchParams,
+} from '@/utilities/campaignListUrl'
+
+export const contactPageSize = 25
+
+export type ContactGender = (typeof leadershipGenders)[number]
+
+export type ContactStateKey = keyof typeof CitiesByState
+
+/** Labels mirroring the Contact collection options (Fase 3: 'outro' → 'Não binário'). */
+export const contactGenderLabels: Record = {
+ feminino: 'Feminino',
+ masculino: 'Masculino',
+ outro: 'Não binário',
+ nao_informado: 'Não informado',
+}
+
+export const CONTACT_ABSENCES = ['sem_telefone', 'sem_email'] as const
+export type ContactAbsence = (typeof CONTACT_ABSENCES)[number]
+
+export const contactAbsenceLabels: Record = {
+ sem_telefone: 'Sem telefone',
+ sem_email: 'Sem e-mail',
+}
+
+export const CONTACT_VINCULOS = ['liderancas', 'dobradinhas', 'assessores', 'equipe'] as const
+export type ContactVinculo = (typeof CONTACT_VINCULOS)[number]
+
+export const contactVinculoLabels: Record = {
+ liderancas: 'Lideranças',
+ dobradinhas: 'Dobradinhas',
+ assessores: 'Assessores',
+ equipe: 'Equipe',
+}
+
+export type ContactListSortKey = 'name' | 'cidade' | 'estado' | 'email'
+
+export type ContactListSortDirection = 'asc' | 'desc'
+
+export const contactListSortLabels: Record = {
+ name: 'Nome',
+ cidade: 'Cidade',
+ estado: 'Estado',
+ email: 'E-mail',
+}
+
+export type ContactListState = {
+ page: number
+ q?: string
+ genders?: ContactGender[]
+ states?: ContactStateKey[]
+ cities?: string[]
+ ausencias?: ContactAbsence[]
+ vinculos?: ContactVinculo[]
+ sort?: ContactListSortKey
+ dir?: ContactListSortDirection
+}
+
+export type ContactListSearchParams = RawSearchParams
+
+const contactListParamNames = [
+ 'q',
+ 'gender',
+ 'state',
+ 'city',
+ 'ausencia',
+ 'vinculo',
+ 'sort',
+ 'dir',
+ 'page',
+] as const
+const contactListParamNameSet = new Set(contactListParamNames)
+
+const contactGenderSet = new Set(leadershipGenders)
+const contactStateSet = new Set(Object.keys(CitiesByState))
+const contactAbsenceSet = new Set(CONTACT_ABSENCES)
+const contactVinculoSet = new Set(CONTACT_VINCULOS)
+const contactListSortKeySet = new Set(Object.keys(contactListSortLabels))
+const contactListSortDirSet = new Set(['asc', 'desc'])
+
+export const defaultContactListSortDir = (_key: ContactListSortKey): ContactListSortDirection =>
+ 'asc'
+
+export const resolveContactListSort = (
+ state: ContactListState,
+): { sort: ContactListSortKey; dir: ContactListSortDirection } => {
+ const sort = state.sort ?? 'name'
+ return { sort, dir: state.dir ?? defaultContactListSortDir(sort) }
+}
+
+export const isDefaultContactListSort = (state: ContactListState): boolean => {
+ const { sort, dir } = resolveContactListSort(state)
+ return sort === 'name' && dir === defaultContactListSortDir('name')
+}
+
+/**
+ * C139 — absence and vínculo facets do NOT use `parseExhaustiveEnumParam`:
+ * selecting every member is NOT the same filter as selecting none (the
+ * absence "Sem telefone"+"Sem e-mail" still excludes complete fichas; the
+ * vínculo "any of them" still excludes fichas without a link) — same
+ * rational as the C125 umbrella.
+ */
+const parseContactAbsenceParam = (raw: string | string[] | undefined): ContactAbsence[] =>
+ allParamValues(raw).filter((token): token is ContactAbsence => contactAbsenceSet.has(token))
+
+const parseContactVinculoParam = (raw: string | string[] | undefined): ContactVinculo[] =>
+ allParamValues(raw).filter((token): token is ContactVinculo => contactVinculoSet.has(token))
+
+export const parseContactListParams = (params: ContactListSearchParams): ContactListState => {
+ const rawPage = strictDecimalInteger(firstValue(params.page))
+ const q = normalizedText(firstValue(params.q))
+ const genders = allParamValues(params.gender).filter((token): token is ContactGender =>
+ contactGenderSet.has(token),
+ )
+ const states = allParamValues(params.state).filter((token): token is ContactStateKey =>
+ contactStateSet.has(token),
+ )
+ const cities = allParamValues(params.city).filter((token) => token.length <= 100)
+ const ausencias = parseContactAbsenceParam(params.ausencia)
+ const vinculos = parseContactVinculoParam(params.vinculo)
+ const rawSort = firstValue(params.sort)
+ const sort =
+ rawSort && contactListSortKeySet.has(rawSort) ? (rawSort as ContactListSortKey) : undefined
+ const rawDir = firstValue(params.dir)
+ const dir =
+ rawDir && contactListSortDirSet.has(rawDir as ContactListSortDirection)
+ ? (rawDir as ContactListSortDirection)
+ : undefined
+
+ return {
+ page: rawPage ?? 1,
+ ...(q ? { q } : {}),
+ ...(genders.length ? { genders } : {}),
+ ...(states.length ? { states } : {}),
+ ...(cities.length ? { cities } : {}),
+ ...(ausencias.length ? { ausencias } : {}),
+ ...(vinculos.length ? { vinculos } : {}),
+ ...(sort ? { sort } : {}),
+ ...(dir ? { dir } : {}),
+ }
+}
+
+export const contactListStateToRawParams = (
+ state: ContactListState,
+ page = state.page,
+): ContactListSearchParams => ({
+ page: String(page),
+ q: state.q,
+ gender: state.genders,
+ state: state.states,
+ city: state.cities,
+ ausencia: state.ausencias,
+ vinculo: state.vinculos,
+ sort: state.sort,
+ dir: state.dir,
+})
+
+/** Expects already-canonical state (from parse or a rule-preserving toggle). */
+export const serializeCanonicalContactListSearchParams = (
+ canonicalState: ContactListState,
+): URLSearchParams => {
+ const params = new URLSearchParams()
+ if (canonicalState.q) params.set('q', canonicalState.q)
+ for (const gender of canonicalState.genders ?? []) params.append('gender', gender)
+ for (const state of canonicalState.states ?? []) params.append('state', state)
+ for (const city of canonicalState.cities ?? []) params.append('city', city)
+ for (const ausencia of canonicalState.ausencias ?? []) params.append('ausencia', ausencia)
+ for (const vinculo of canonicalState.vinculos ?? []) params.append('vinculo', vinculo)
+ if (!isDefaultContactListSort(canonicalState)) {
+ const { sort, dir } = resolveContactListSort(canonicalState)
+ params.set('sort', sort)
+ if (dir !== defaultContactListSortDir(sort)) params.set('dir', dir)
+ }
+ if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
+ return params
+}
+
+const buildContactListSearchParams = (
+ state: ContactListState,
+ page = state.page,
+): URLSearchParams =>
+ serializeCanonicalContactListSearchParams(
+ parseContactListParams(contactListStateToRawParams(state, page)),
+ )
+
+export const buildContactListHref = (state: ContactListState, page: number): string =>
+ buildListHref(state, buildContactListSearchParams, '/campanha/contatos', page)
+
+export const buildContactSortHref = createSortToggleHref({
+ resolveCurrentSort: resolveContactListSort,
+ defaultDir: defaultContactListSortDir,
+ buildHref: (state) => buildContactListHref(state, 1),
+})
+
+const contactSortOptionLabel = (key: ContactListSortKey, dir: ContactListSortDirection): string =>
+ `${contactListSortLabels[key]} (${dir === 'asc' ? 'A–Z' : 'Z–A'})`
+
+export const contactListSortOptions = (
+ Object.keys(contactListSortLabels) as ContactListSortKey[]
+).flatMap((key) =>
+ (['asc', 'desc'] as const).map((dir) => ({ key, dir, label: contactSortOptionLabel(key, dir) })),
+)
+
+/** One option per key, in the key's default direction (omnibox cap precedent, C125). */
+export const contactListSortPrimaryOptions = contactListSortOptions.filter(
+ ({ key, dir }) => dir === defaultContactListSortDir(key),
+)
+
+export const resolveContactListUrl = (
+ params: ContactListSearchParams,
+ totalPages?: number,
+): {
+ state: ContactListState
+ href: string
+ redirectHref?: string
+} =>
+ resolveListUrl({
+ params,
+ paramNameSet: contactListParamNameSet,
+ parse: parseContactListParams,
+ buildSearchParams: buildContactListSearchParams,
+ basePath: '/campanha/contatos',
+ totalPages,
+ })
+
+/**
+ * Source-level `where`: `q` matches name, e-mail or ANY stored phone. The
+ * `phones.value` array subfield and the `like` operator (ILIKE substring,
+ * case-insensitive) were verified against the local Postgres DB during the
+ * C139 tracer phase (2026-08-13).
+ */
+export const buildContactListWhere = (state: ContactListState): Where => {
+ if (!state.q) return {}
+ return {
+ or: [
+ { name: { like: state.q } },
+ { email: { like: state.q } },
+ { 'phones.value': { like: state.q } },
+ ],
+ }
+}
diff --git a/src/utilities/contacts/contactNameInvariant.ts b/src/utilities/contacts/contactNameInvariant.ts
new file mode 100644
index 00000000..d3596285
--- /dev/null
+++ b/src/utilities/contacts/contactNameInvariant.ts
@@ -0,0 +1,57 @@
+import 'server-only'
+
+import type { Payload, PayloadRequest } from 'payload'
+
+import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
+
+export const CONTACT_NAME_CONFLICT_MESSAGE =
+ 'Já existe um contato com este nome — confira a lista antes de salvar.'
+
+type ContactNameRequest = Pick
+
+/**
+ * C139 — the Contact ficha is the "person" record and the contacts page lets
+ * staff edit names in place, so the name becomes a first-class lookup key:
+ * normalize (trim + collapse whitespace + lowercase), compare exactly on the
+ * normalized form. The DB query is only a case-insensitive substring
+ * prefilter (the Postgres adapter has no normalized-equality operator); the
+ * verdict is the in-memory exact check.
+ */
+export const normalizeContactName = (name: string): string =>
+ name.trim().replace(/\s+/g, ' ').toLowerCase()
+
+export const assertContactNameAvailable = async (
+ payload: Pick,
+ req: ContactNameRequest,
+ name: string,
+ contactID?: number,
+): Promise => {
+ const normalized = normalizeContactName(name)
+
+ if (req.transactionID !== undefined && req.transactionID !== null) {
+ await acquireTextAdvisoryLocks(payload, req, [`contact-name:${normalized}`])
+ }
+
+ const candidates = await payload.find({
+ collection: 'contact',
+ where: {
+ and: [
+ { name: { like: normalized } },
+ ...(contactID === undefined ? [] : [{ id: { not_equals: contactID } }]),
+ ],
+ },
+ depth: 0,
+ limit: 0,
+ pagination: false,
+ select: { name: true },
+ // Intentional bypass: the invariant must see rows outside the current
+ // actor scope (same rationale as the stateDeputy name invariant).
+ overrideAccess: true,
+ req,
+ })
+
+ const conflict = (candidates.docs as { name: string }[]).some(
+ (doc) => normalizeContactName(doc.name) === normalized,
+ )
+ if (conflict) throw new Error(CONTACT_NAME_CONFLICT_MESSAGE)
+}
diff --git a/tests/e2e/campaignBottomNav.e2e.spec.ts b/tests/e2e/campaignBottomNav.e2e.spec.ts
index aacd9c42..4cf2d06a 100644
--- a/tests/e2e/campaignBottomNav.e2e.spec.ts
+++ b/tests/e2e/campaignBottomNav.e2e.spec.ts
@@ -102,7 +102,7 @@ test.describe('Mobile bottom nav (B164)', () => {
test('leader does not see the bottom nav', async ({ campaign, context, page }) => {
await seedCampaignSession(context, campaign.baseURL, leaderToken)
- await page.goto('/campanha/contatos')
+ await page.goto('/campanha/meus-contatos')
await expect(page.locator('[aria-label="Navegação principal"]')).toHaveCount(0)
})
@@ -121,7 +121,7 @@ test.describe('Mobile bottom nav (B164)', () => {
test('leader keeps the sidebar sheet on mobile (C102)', async ({ campaign, context, page }) => {
await seedCampaignSession(context, campaign.baseURL, leaderToken)
- await page.goto('/campanha/contatos')
+ await page.goto('/campanha/meus-contatos')
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"]')
const trigger = topBar.locator('[data-slot="sidebar-trigger"]')
diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts
index e98dbb8d..490fa1ac 100644
--- a/tests/e2e/campaignMunicipalities.e2e.spec.ts
+++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts
@@ -710,17 +710,17 @@ test.describe('Municípios — jornadas por papel', () => {
await expect(page.getByText('Média: 90')).toBeVisible()
// Leader home is the blank Início (B43); the contact tool lives at
- // /campanha/contatos and municipalities redirect there.
+ // /campanha/meus-contatos (C139) and municipalities redirect there.
// Each `createCampaignUser` call mints its own password — the advisor's
// `password` variable above does NOT unlock the leader account.
await campaign.login(page, leaderPhone, leaderAccount.password)
- await page.goto(`${campaign.baseURL}/campanha/contatos`)
- await expect(campaignPageChrome(page, 'Contatos')).toBeVisible()
+ await page.goto(`${campaign.baseURL}/campanha/meus-contatos`)
+ await expect(campaignPageChrome(page, 'Meus contatos')).toBeVisible()
// The leader redirect aborts `goto`'s load event (ERR_ABORTED); the
// redirect itself is the assertion, and `toHaveURL` retries onto it.
await page.goto(`${campaign.baseURL}/campanha/municipios`).catch(() => {})
- await expect(page).toHaveURL(`${campaign.baseURL}/campanha/contatos`)
+ await expect(page).toHaveURL(`${campaign.baseURL}/campanha/meus-contatos`)
const supporterName = fixtures.value('Apoiador Liderança')
const supporterPhone = fixtures.phone()
diff --git a/tests/e2e/campaignPeople.e2e.spec.ts b/tests/e2e/campaignPeople.e2e.spec.ts
index 3ba46d9e..0e4f4c30 100644
--- a/tests/e2e/campaignPeople.e2e.spec.ts
+++ b/tests/e2e/campaignPeople.e2e.spec.ts
@@ -113,8 +113,8 @@ test.describe('Pessoas — lista unificada', () => {
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()
+ await expect(page).toHaveURL(/\/campanha\/meus-contatos/)
+ await expect(page.getByRole('heading', { name: 'Meus contatos' })).toBeVisible()
})
test('leader cannot open the person detail route', async ({ campaign, page }) => {
@@ -128,8 +128,8 @@ test.describe('Pessoas — lista unificada', () => {
await campaign.login(page, phone, leader.password)
await page.goto('/campanha/pessoas/99999')
- await expect(page).toHaveURL(/\/campanha\/contatos/)
- await expect(page.getByRole('heading', { name: 'Contatos' })).toBeVisible()
+ await expect(page).toHaveURL(/\/campanha\/meus-contatos/)
+ await expect(page.getByRole('heading', { name: 'Meus contatos' })).toBeVisible()
})
test('coordinator opens the person detail and sees the sections of her capacities', async ({
diff --git a/tests/e2e/campaignRegisterDemand.e2e.spec.ts b/tests/e2e/campaignRegisterDemand.e2e.spec.ts
index 4e0551f7..f83cf6da 100644
--- a/tests/e2e/campaignRegisterDemand.e2e.spec.ts
+++ b/tests/e2e/campaignRegisterDemand.e2e.spec.ts
@@ -85,7 +85,7 @@ test.describe('Wizard "Registrar pedido" (A5/B195)', () => {
await page
.goto(`${campaign.baseURL}/campanha/acoes/registrar-pedido?municipio=${municipality.slug}`)
.catch(() => {})
- await expect(page).toHaveURL(`${campaign.baseURL}/campanha/contatos`)
+ await expect(page).toHaveURL(`${campaign.baseURL}/campanha/meus-contatos`)
await expect(page.getByLabel(CAMPAIGN_DEMAND_BODY_LABEL)).toHaveCount(0)
})
})
diff --git a/tests/e2e/campaignTerritoriesHttp.e2e.spec.ts b/tests/e2e/campaignTerritoriesHttp.e2e.spec.ts
index c87d3957..9fddba33 100644
--- a/tests/e2e/campaignTerritoriesHttp.e2e.spec.ts
+++ b/tests/e2e/campaignTerritoriesHttp.e2e.spec.ts
@@ -144,14 +144,14 @@ test.describe('Territórios de Identidade (HTTP)', () => {
expect([200, 307, 308]).toContain(direct.status())
if (direct.status() === 200) {
expect(await direct.text()).toContain(
- 'http-equiv="refresh" content="1;url=/campanha/contatos"',
+ 'http-equiv="refresh" content="1;url=/campanha/meus-contatos"',
)
} else {
- expect(direct.headers()['location']).toMatch(/\/campanha\/contatos$/)
+ expect(direct.headers()['location']).toMatch(/\/campanha\/meus-contatos$/)
}
// The leader home renders no way in.
- const home = await request.get('/campanha/contatos')
+ const home = await request.get('/campanha/meus-contatos')
expect(home.status()).toBe(200)
expect(await home.text()).not.toContain('href="/campanha/territorios"')
})
diff --git a/tests/e2e/setup.e2e.spec.ts b/tests/e2e/setup.e2e.spec.ts
index 0dded7b1..94991fef 100644
--- a/tests/e2e/setup.e2e.spec.ts
+++ b/tests/e2e/setup.e2e.spec.ts
@@ -41,6 +41,9 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
// (same class as /editar: cold compile mid-test aborts the redirect).
'/campanha/demandas/e2e-prewarm',
'/campanha/liderancas',
+ // C139 — both contact surfaces: the leader tool (meus-contatos, which the
+ // lockdown redirects land on) and the new staff ficha page (contatos).
+ '/campanha/meus-contatos',
'/campanha/contatos',
'/campanha/conceitos',
'/campanha/acoes/atualizar-votos',
diff --git a/tests/unit/campaignPageChrome.unit.spec.ts b/tests/unit/campaignPageChrome.unit.spec.ts
index 5c789a8f..74593594 100644
--- a/tests/unit/campaignPageChrome.unit.spec.ts
+++ b/tests/unit/campaignPageChrome.unit.spec.ts
@@ -54,6 +54,18 @@ describe('campaignPageChrome', () => {
it('builds tab metadata from catalog', () => {
expect(campaignPageMetadataFromCatalog('contatos').title).toBe('Contatos')
+ expect(campaignPageMetadataFromCatalog('meusContatos').title).toBe('Meus contatos')
expect(campaignPageMetadata(null).title).toBe('Início')
})
+
+ it('separates staff contatos from the leader meus-contatos (C139)', () => {
+ expect(resolveCampaignPageChrome('/campanha/contatos', 'coordinator')).toEqual({
+ title: 'Contatos',
+ subtitle: 'Fichas da campanha — atualize dados e fale com as pessoas.',
+ })
+ expect(resolveCampaignPageChrome('/campanha/meus-contatos', 'leader')).toEqual({
+ title: 'Meus contatos',
+ subtitle: 'Cadastre apoiadores pelo celular. Só você vê os contatos que criou aqui.',
+ })
+ })
})
diff --git a/tests/unit/campaignQuickAction.unit.spec.ts b/tests/unit/campaignQuickAction.unit.spec.ts
index dc9c9b49..bd900c2d 100644
--- a/tests/unit/campaignQuickAction.unit.spec.ts
+++ b/tests/unit/campaignQuickAction.unit.spec.ts
@@ -22,6 +22,7 @@ import {
import {
isCampaignActionsPath,
isCampaignHomePath,
+ isContactsPath,
isLeaderContactsPath,
shouldMountQuickActionsFab,
} from '@/lib/campaignQuickActionMount'
@@ -54,13 +55,21 @@ describe('campaignQuickActionMount', () => {
expect(isCampaignActionsPath('/campanha/municipios')).toBe(false)
})
- it('matches leader contacts subtree', () => {
- expect(isLeaderContactsPath('/campanha/contatos')).toBe(true)
- expect(isLeaderContactsPath('/campanha/contatos/novo')).toBe(true)
+ it('matches leader contacts subtree (C139 — moved to meus-contatos)', () => {
+ expect(isLeaderContactsPath('/campanha/meus-contatos')).toBe(true)
+ expect(isLeaderContactsPath('/campanha/meus-contatos/novo')).toBe(true)
+ expect(isLeaderContactsPath('/campanha/contatos')).toBe(false)
expect(isLeaderContactsPath('/campanha')).toBe(false)
})
- it('mounts for staff outside Início and acoes', () => {
+ it('matches the staff contacts page (C139)', () => {
+ expect(isContactsPath('/campanha/contatos')).toBe(true)
+ expect(isContactsPath('/campanha/contatos/')).toBe(true)
+ expect(isContactsPath('/campanha/meus-contatos')).toBe(false)
+ expect(isContactsPath('/campanha')).toBe(false)
+ })
+
+ it('mounts for staff outside Início, acoes and the contacts page', () => {
expect(shouldMountQuickActionsFab('/campanha/municipios', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/territorios', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/demandas', 'coordinator')).toBe(true)
@@ -71,12 +80,15 @@ describe('campaignQuickActionMount', () => {
expect(shouldMountQuickActionsFab('/campanha/acoes/registrar-atualizacao', 'advisor')).toBe(
false,
)
+ expect(shouldMountQuickActionsFab('/campanha/contatos', 'coordinator')).toBe(false)
+ expect(shouldMountQuickActionsFab('/campanha/contatos', 'advisor')).toBe(false)
})
it('mounts for leader only on contacts', () => {
- expect(shouldMountQuickActionsFab('/campanha/contatos', 'leader')).toBe(true)
+ expect(shouldMountQuickActionsFab('/campanha/meus-contatos', 'leader')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/municipios', 'leader')).toBe(false)
expect(shouldMountQuickActionsFab('/campanha/apoiadores', 'leader')).toBe(false)
+ expect(shouldMountQuickActionsFab('/campanha/contatos', 'leader')).toBe(false)
})
it('skips the E13 tour composer (B84)', () => {
diff --git a/tests/unit/contactListData.unit.spec.ts b/tests/unit/contactListData.unit.spec.ts
new file mode 100644
index 00000000..e444dc92
--- /dev/null
+++ b/tests/unit/contactListData.unit.spec.ts
@@ -0,0 +1,234 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ contactFilterFacetsFromRows,
+ filterContactRows,
+ sortContactRows,
+ toContactRowViewModel,
+ type ContactRowViewModel,
+} from '@/utilities/contacts/contactListData'
+import type { ContactListState } from '@/utilities/contacts/contactListUrl'
+
+const baseRow = (overrides: Partial = {}): ContactRowViewModel => ({
+ contactID: 1,
+ name: 'Ana Souza',
+ email: 'ana@example.com',
+ phones: ['71999990000'],
+ gender: 'feminino',
+ state: 'BA',
+ city: 'Salvador',
+ postalCode: '40000-000',
+ vinculos: ['liderancas'],
+ ...overrides,
+})
+
+const rows = [
+ baseRow({ contactID: 1, name: 'Ana Souza', gender: 'feminino', state: 'BA', city: 'Salvador' }),
+ baseRow({
+ contactID: 2,
+ name: 'Bruno Lima',
+ gender: 'masculino',
+ state: 'SP',
+ city: 'São Paulo',
+ email: null,
+ vinculos: [],
+ }),
+ baseRow({
+ contactID: 3,
+ name: 'Carla Mendes',
+ gender: null,
+ state: null,
+ city: null,
+ phones: [],
+ email: 'carla@example.com',
+ vinculos: ['dobradinhas', 'equipe'],
+ }),
+]
+
+describe('toContactRowViewModel', () => {
+ it('maps the contact doc keeping phone order and normalizing optionals', () => {
+ const view = toContactRowViewModel({
+ id: 7,
+ name: ' Maria ',
+ email: 'maria@example.com',
+ phones: [{ value: '71 9' }, { value: '75 8' }],
+ gender: 'outro',
+ state: 'BA',
+ city: 'Camaçari',
+ postalCode: '42800-000',
+ } as never)
+ expect(view).toEqual({
+ contactID: 7,
+ name: ' Maria ',
+ email: 'maria@example.com',
+ phones: ['71 9', '75 8'],
+ gender: 'outro',
+ state: 'BA',
+ city: 'Camaçari',
+ postalCode: '42800-000',
+ vinculos: [],
+ })
+ })
+
+ it('degrades unknown enum values and absent optionals to null', () => {
+ const view = toContactRowViewModel({
+ id: 8,
+ name: 'Sem Dados',
+ email: null,
+ phones: null,
+ gender: 'desconhecido',
+ state: 'BAHIA',
+ city: null,
+ postalCode: null,
+ } as never)
+ expect(view).toEqual({
+ contactID: 8,
+ name: 'Sem Dados',
+ email: null,
+ phones: [],
+ gender: null,
+ state: null,
+ city: null,
+ postalCode: null,
+ vinculos: [],
+ })
+ })
+})
+
+describe('filterContactRows', () => {
+ const state = (partial: Partial): ContactListState => ({
+ page: 1,
+ ...partial,
+ })
+
+ it('keeps every row when no filter is active', () => {
+ expect(filterContactRows(rows, state({}))).toHaveLength(3)
+ })
+
+ it('filters by gender and drops rows without a gender', () => {
+ expect(
+ filterContactRows(rows, state({ genders: ['feminino'] })).map((r) => r.contactID),
+ ).toEqual([1])
+ expect(
+ filterContactRows(rows, state({ genders: ['masculino'] })).map((r) => r.contactID),
+ ).toEqual([2])
+ })
+
+ it('filters by state and drops rows without a state', () => {
+ expect(filterContactRows(rows, state({ states: ['BA'] })).map((r) => r.contactID)).toEqual([1])
+ })
+
+ it('filters by city case-insensitively', () => {
+ expect(
+ filterContactRows(rows, state({ cities: ['salvador'] })).map((r) => r.contactID),
+ ).toEqual([1])
+ expect(
+ filterContactRows(rows, state({ cities: ['Salvador', 'São Paulo'] })).map((r) => r.contactID),
+ ).toEqual([1, 2])
+ })
+
+ it('filters by absence with OR semantics and never matches a complete ficha', () => {
+ expect(
+ filterContactRows(rows, state({ ausencias: ['sem_telefone'] })).map((r) => r.contactID),
+ ).toEqual([3])
+ expect(
+ filterContactRows(rows, state({ ausencias: ['sem_email'] })).map((r) => r.contactID),
+ ).toEqual([2])
+ expect(
+ filterContactRows(rows, state({ ausencias: ['sem_telefone', 'sem_email'] })).map(
+ (r) => r.contactID,
+ ),
+ ).toEqual([2, 3])
+ })
+
+ it('filters by vínculo with OR semantics (any of the selected links)', () => {
+ expect(
+ filterContactRows(rows, state({ vinculos: ['liderancas'] })).map((r) => r.contactID),
+ ).toEqual([1])
+ expect(
+ filterContactRows(rows, state({ vinculos: ['dobradinhas', 'liderancas'] })).map(
+ (r) => r.contactID,
+ ),
+ ).toEqual([1, 3])
+ // A ficha without any link never matches a vínculo filter.
+ expect(
+ filterContactRows(rows, state({ vinculos: ['assessores'] })).map((r) => r.contactID),
+ ).toEqual([])
+ })
+
+ it('combines filters with AND between facets', () => {
+ expect(
+ filterContactRows(rows, state({ genders: ['feminino'], states: ['BA'] })).map(
+ (r) => r.contactID,
+ ),
+ ).toEqual([1])
+ expect(
+ filterContactRows(rows, state({ genders: ['feminino'], states: ['SP'] })).map(
+ (r) => r.contactID,
+ ),
+ ).toEqual([])
+ })
+})
+
+describe('contactFilterFacetsFromRows', () => {
+ it('derives facets from the scoped rows, seeded with the active selections', () => {
+ expect(
+ contactFilterFacetsFromRows(rows, { page: 1, genders: ['feminino'], states: ['BA'] }),
+ ).toEqual({
+ genders: ['feminino', 'masculino'],
+ states: ['BA', 'SP'],
+ cities: ['Salvador', 'São Paulo'],
+ vinculos: ['dobradinhas', 'equipe', 'liderancas'],
+ })
+ })
+
+ it('keeps the first-seen city casing when deduping case-insensitively', () => {
+ expect(
+ contactFilterFacetsFromRows(
+ [baseRow({ contactID: 1, city: 'Salvador' }), baseRow({ contactID: 2, city: 'salvador' })],
+ { page: 1 },
+ ).cities,
+ ).toEqual(['Salvador'])
+ })
+
+ it('returns empty facets for an empty scope', () => {
+ expect(contactFilterFacetsFromRows([], { page: 1 })).toEqual({
+ genders: [],
+ states: [],
+ cities: [],
+ vinculos: [],
+ })
+ })
+})
+
+describe('sortContactRows', () => {
+ it('sorts by name asc by default, ties by contact id', () => {
+ expect(sortContactRows(rows, 'name', 'asc').map((r) => r.contactID)).toEqual([1, 2, 3])
+ expect(sortContactRows(rows, 'name', 'desc').map((r) => r.contactID)).toEqual([3, 2, 1])
+ })
+
+ it('sorts by city and estado, landing nulls last in both directions', () => {
+ const byCityAsc = sortContactRows(rows, 'cidade', 'asc')
+ expect(byCityAsc.map((r) => r.contactID)).toEqual([1, 2, 3])
+ expect(byCityAsc[2].city).toBeNull()
+
+ const byCityDesc = sortContactRows(rows, 'cidade', 'desc')
+ expect(byCityDesc.map((r) => r.contactID)).toEqual([2, 1, 3])
+
+ const byEstadoAsc = sortContactRows(rows, 'estado', 'asc')
+ expect(byEstadoAsc.map((r) => r.contactID)).toEqual([1, 2, 3])
+ })
+
+ it('sorts by email, landing null emails last', () => {
+ const byEmailAsc = sortContactRows(rows, 'email', 'asc')
+ expect(byEmailAsc.map((r) => r.contactID)).toEqual([1, 3, 2])
+ const byEmailDesc = sortContactRows(rows, 'email', 'desc')
+ expect(byEmailDesc.map((r) => r.contactID)).toEqual([3, 1, 2])
+ })
+
+ it('does not mutate the input rows', () => {
+ const before = [...rows].map((r) => r.contactID)
+ sortContactRows(rows, 'name', 'desc')
+ expect(rows.map((r) => r.contactID)).toEqual(before)
+ })
+})
diff --git a/tests/unit/contactListUrl.unit.spec.ts b/tests/unit/contactListUrl.unit.spec.ts
new file mode 100644
index 00000000..e81d40cc
--- /dev/null
+++ b/tests/unit/contactListUrl.unit.spec.ts
@@ -0,0 +1,206 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ buildContactListHref,
+ buildContactListWhere,
+ buildContactSortHref,
+ contactListSortPrimaryOptions,
+ defaultContactListSortDir,
+ parseContactListParams,
+ resolveContactListUrl,
+ serializeCanonicalContactListSearchParams,
+} from '@/utilities/contacts/contactListUrl'
+
+describe('contacts list URL contract', () => {
+ it('defaults to page 1 with no filters', () => {
+ expect(parseContactListParams({})).toEqual({ page: 1 })
+ })
+
+ it('validates page as a positive decimal integer', () => {
+ expect(parseContactListParams({ page: '4' }).page).toBe(4)
+ expect(parseContactListParams({ page: '0' }).page).toBe(1)
+ expect(parseContactListParams({ page: '-2' }).page).toBe(1)
+ expect(parseContactListParams({ page: 'abc' }).page).toBe(1)
+ })
+
+ it('trims q, omits it when empty and takes the first repeated cell', () => {
+ expect(parseContactListParams({ q: ' ana ' }).q).toBe('ana')
+ expect(parseContactListParams({ q: ' ' }).q).toBeUndefined()
+ expect(parseContactListParams({ q: ['primeiro', 'segundo'] }).q).toBe('primeiro')
+ })
+
+ it('parses and dedupes known gender and state values', () => {
+ expect(parseContactListParams({ gender: 'feminino' })).toEqual({
+ page: 1,
+ genders: ['feminino'],
+ })
+ expect(
+ parseContactListParams({ gender: ['masculino', 'outro', 'masculino', 'unknown'] }).genders,
+ ).toEqual(['masculino', 'outro'])
+ expect(parseContactListParams({ state: 'BA' }).states).toEqual(['BA'])
+ expect(parseContactListParams({ state: ['BA', 'SP', 'BA', 'XX', 'bA'] }).states).toEqual([
+ 'BA',
+ 'SP',
+ ])
+ })
+
+ it('parses cities as deduped free text up to a sanity cap', () => {
+ expect(parseContactListParams({ city: ['Salvador', 'salvador', ' '] }).cities).toEqual([
+ 'Salvador',
+ 'salvador',
+ ])
+ expect(parseContactListParams({ city: ['x'.repeat(101)] }).cities).toBeUndefined()
+ })
+
+ it('parses absence facets as OR values, dropping unknown (no "todas" collapse)', () => {
+ expect(parseContactListParams({ ausencia: 'sem_telefone' })).toEqual({
+ page: 1,
+ ausencias: ['sem_telefone'],
+ })
+ expect(
+ parseContactListParams({ ausencia: ['sem_telefone', 'sem_email', 'sem_telefone', 'nada'] })
+ .ausencias,
+ ).toEqual(['sem_telefone', 'sem_email'])
+ // C139 — selecting every absence still filters (fichas sem telefone E sem
+ // e-mail): no canonical "todas" collapse for this facet.
+ expect(parseContactListParams({ ausencia: ['sem_telefone', 'sem_email'] }).ausencias).toEqual([
+ 'sem_telefone',
+ 'sem_email',
+ ])
+ })
+
+ it('parses vínculo facets as OR values, dropping unknown', () => {
+ expect(parseContactListParams({ vinculo: 'liderancas' })).toEqual({
+ page: 1,
+ vinculos: ['liderancas'],
+ })
+ expect(
+ parseContactListParams({ vinculo: ['dobradinhas', 'equipe', 'dobradinhas', 'nada'] })
+ .vinculos,
+ ).toEqual(['dobradinhas', 'equipe'])
+ })
+
+ it('parses known sort keys and directions, dropping anything else', () => {
+ expect(parseContactListParams({ sort: 'cidade', dir: 'asc' })).toEqual({
+ page: 1,
+ sort: 'cidade',
+ dir: 'asc',
+ })
+ expect(parseContactListParams({ sort: 'party', dir: 'up' })).toEqual({ page: 1 })
+ expect(parseContactListParams({ sort: 'name' })).toEqual({ page: 1, sort: 'name' })
+ expect(parseContactListParams({ sort: 'party' })).toEqual({ page: 1 })
+ expect(parseContactListParams({ dir: 'desc' })).toEqual({ page: 1, dir: 'desc' })
+ })
+
+ it('serializes canonical params in stable order and omits defaults', () => {
+ expect(
+ serializeCanonicalContactListSearchParams({
+ page: 1,
+ q: 'ana',
+ genders: ['feminino'],
+ states: ['BA'],
+ cities: ['Salvador'],
+ ausencias: ['sem_email'],
+ vinculos: ['liderancas'],
+ }).toString(),
+ ).toBe('q=ana&gender=feminino&state=BA&city=Salvador&ausencia=sem_email&vinculo=liderancas')
+
+ expect(
+ serializeCanonicalContactListSearchParams({ page: 1, sort: 'name', dir: 'asc' }).toString(),
+ ).toBe('')
+ expect(
+ serializeCanonicalContactListSearchParams({
+ page: 1,
+ sort: 'cidade',
+ dir: 'desc',
+ }).toString(),
+ ).toBe('sort=cidade&dir=desc')
+ expect(serializeCanonicalContactListSearchParams({ page: 3, q: 'ana' }).toString()).toBe(
+ 'q=ana&page=3',
+ )
+ })
+
+ it('builds hrefs through the canonical serializer and drops page 1', () => {
+ expect(buildContactListHref({ page: 1, vinculos: ['dobradinhas'] }, 1)).toBe(
+ '/campanha/contatos?vinculo=dobradinhas',
+ )
+ expect(buildContactListHref({ page: 3 }, 3)).toBe('/campanha/contatos?page=3')
+ })
+
+ it('resolves a bare URL to the bare path and rejects unsupported params', () => {
+ const resolved = resolveContactListUrl({ q: 'ana' })
+ expect(resolved.state).toEqual({ page: 1, q: 'ana' })
+ expect(resolved.href).toBe('/campanha/contatos?q=ana')
+ expect(resolved.redirectHref).toBeUndefined()
+
+ const unsupported = resolveContactListUrl({ q: 'ana', party: 'PT' })
+ expect(unsupported.redirectHref).toBe('/campanha/contatos?q=ana')
+ })
+
+ it('canonicalizes a default sort away and keeps a non-default one', () => {
+ const defaulted = resolveContactListUrl({ q: 'ana', sort: 'name' })
+ expect(defaulted.state).toEqual({ page: 1, q: 'ana', sort: 'name' })
+ expect(defaulted.redirectHref).toBe('/campanha/contatos?q=ana')
+
+ // Every contacts key defaults to asc, so a non-default dir IS canonical
+ // (no redirect) — unlike people, whose 'lidera' default is desc.
+ const kept = resolveContactListUrl({ sort: 'cidade', dir: 'desc' })
+ expect(kept.redirectHref).toBeUndefined()
+ expect(kept.href).toBe('/campanha/contatos?sort=cidade&dir=desc')
+
+ // A bare dir resolves against the default key and canonicalizes without a loop.
+ const bareDir = resolveContactListUrl({ dir: 'desc' })
+ expect(bareDir.state).toEqual({ page: 1, dir: 'desc' })
+ expect(bareDir.redirectHref).toBe('/campanha/contatos?sort=name&dir=desc')
+ })
+
+ it('builds sort-toggle hrefs: flips the active key, applies the new key default, resets to page 1', () => {
+ expect(buildContactSortHref({ page: 1 }, 'cidade')).toBe('/campanha/contatos?sort=cidade')
+ // Flip from desc → asc, the key default: the dir canonicalizes away.
+ expect(buildContactSortHref({ page: 1, sort: 'cidade', dir: 'desc' }, 'cidade')).toBe(
+ '/campanha/contatos?sort=cidade',
+ )
+ expect(buildContactSortHref({ page: 1, sort: 'cidade', dir: 'desc' }, 'email')).toBe(
+ '/campanha/contatos?sort=email',
+ )
+ expect(buildContactSortHref({ page: 1 }, 'email')).toBe('/campanha/contatos?sort=email')
+ expect(buildContactSortHref({ page: 3, sort: 'cidade', dir: 'desc' }, 'name')).toBe(
+ '/campanha/contatos',
+ )
+ })
+
+ it('derives the omnibox primary sort options: one per key, in its default direction', () => {
+ expect(contactListSortPrimaryOptions).toHaveLength(4)
+ expect(contactListSortPrimaryOptions.map((option) => option.key)).toEqual([
+ 'name',
+ 'cidade',
+ 'estado',
+ 'email',
+ ])
+ for (const option of contactListSortPrimaryOptions) {
+ expect(option.dir).toBe(defaultContactListSortDir(option.key))
+ }
+ })
+
+ it('clamps an out-of-range page to totalPages', () => {
+ const resolved = resolveContactListUrl({ page: '9' }, 3)
+ expect(resolved.state.page).toBe(3)
+ expect(resolved.redirectHref).toBe('/campanha/contatos?page=3')
+ })
+})
+
+describe('contacts list source-level where', () => {
+ it('returns an empty where when there is no q', () => {
+ expect(buildContactListWhere({ page: 1 })).toEqual({})
+ })
+
+ it('matches q against name, email or any stored phone', () => {
+ expect(buildContactListWhere({ page: 1, q: 'ana' })).toEqual({
+ or: [
+ { name: { like: 'ana' } },
+ { email: { like: 'ana' } },
+ { 'phones.value': { like: 'ana' } },
+ ],
+ })
+ })
+})
diff --git a/tests/unit/contactNameInvariant.unit.spec.ts b/tests/unit/contactNameInvariant.unit.spec.ts
new file mode 100644
index 00000000..c40bf05c
--- /dev/null
+++ b/tests/unit/contactNameInvariant.unit.spec.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import {
+ assertContactNameAvailable,
+ CONTACT_NAME_CONFLICT_MESSAGE,
+ normalizeContactName,
+} from '@/utilities/contacts/contactNameInvariant'
+
+describe('normalizeContactName', () => {
+ it('trims, collapses internal whitespace and lowercases', () => {
+ expect(normalizeContactName(' Maria da Silva ')).toBe('maria da silva')
+ expect(normalizeContactName('João')).toBe('joão')
+ expect(normalizeContactName('')).toBe('')
+ })
+})
+
+describe('assertContactNameAvailable', () => {
+ const makePayload = (docs: { name: string; id?: number }[]) => ({
+ db: {
+ name: 'postgres',
+ sessions: { tx: { db: { execute: vi.fn() } } },
+ },
+ find: vi.fn().mockImplementation(async ({ where }: { where: any }) => {
+ const conditions: any[] = where?.and ?? []
+ const idExclusion = conditions.find((c) => c.id?.not_equals)?.id.not_equals
+ const like = conditions.find((c) => c.name?.like)?.name.like as string | undefined
+ const filtered = docs.filter((doc) => {
+ if (idExclusion !== undefined && doc.id === idExclusion) return false
+ if (like && !doc.name.toLowerCase().includes(like)) return false
+ return true
+ })
+ return { docs: filtered, totalDocs: filtered.length }
+ }),
+ })
+
+ it('passes when no stored contact normalizes to the same name', async () => {
+ const payload = makePayload([{ id: 9, name: 'Maria Souza' }])
+ await expect(
+ assertContactNameAvailable(payload as never, { transactionID: 'tx' }, ' maria silva '),
+ ).resolves.toBeUndefined()
+ // The DB prefilter runs the case-insensitive substring, the verdict is in memory.
+ expect(payload.find).toHaveBeenCalledOnce()
+ })
+
+ it('throws the conflict message on a normalized-name match (case/whitespace insensitive)', async () => {
+ const payload = makePayload([{ id: 9, name: 'Maria Silva' }])
+ await expect(assertContactNameAvailable(payload as never, {}, 'MARIA SILVA')).rejects.toThrow(
+ CONTACT_NAME_CONFLICT_MESSAGE,
+ )
+ })
+
+ it('ignores the contact itself when renaming (contactID excluded)', async () => {
+ const payload = makePayload([{ id: 9, name: 'Maria Silva' }])
+ await expect(
+ assertContactNameAvailable(payload as never, {}, 'Maria Silva', 9),
+ ).resolves.toBeUndefined()
+ })
+
+ it('does not collide on a partial-name substring match', async () => {
+ const payload = makePayload([{ id: 9, name: 'Ana Maria' }])
+ await expect(assertContactNameAvailable(payload as never, {}, 'Maria')).resolves.toBeUndefined()
+ })
+})