feat(C139): domínio de contatos — URL state, loader com vínculos e invariante de nome

Fase 2 do plano aprovado. Três módulos em src/utilities/contacts/:
- contactListUrl: contrato de params (q|gender|state|city|ausencia|vinculo|sort|dir|page),
  canonicalize, hrefs de sort e where de fonte com 'phones.value' like (ILIKE verificado
  no Postgres local).
- contactListData: loader com UM find em contact (user threadado, overrideAccess false),
  vínculos (liderancas/dobradinhas/assessores/equipe) como facets sobre o conjunto
  legível, filtro/sort/paginação em memória, nulos no fim (precedente B15).
- contactNameInvariant: lock contact-name:{normalized} + prefilter ILIKE + veredito
  exato em memória; mensagem de conflito pt-BR.

38 testes unit novos; format + tsc limpos.
This commit is contained in:
Francisco Solla 2026-08-13 14:05:24 -03:00
parent 85e5aa81e1
commit 1daaaf9f4e
23 changed files with 1470 additions and 28 deletions

View file

@ -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<br/>gate staff] --> D[loadContactListPageData]
D --> F[ContactFilters<br/>omnibox adapter]
D --> T[CampaignTable<br/>células permanent]
D --> C[MobileCards + EditSheet + CreateSheet]
end
subgraph Ações
A1[updateContactField<br/>blur-save célula] --> TX[withPayloadTransaction]
A2[updateContactFull<br/>sheet mobile] --> TX
A3[createContactAction<br/>linha nova / FAB] --> TX
A4[deleteContactAction<br/>manifest C100] --> TX
TX --> N[assertContactNameAvailable<br/>advisory lock contact-name]
TX --> S[assertContactEditable<br/>canReadContacts]
end
subgraph Movida leader
M[meus-contatos/page.tsx<br/>= antigo contatos] --> P[LEADER_CONTACTS_HOME<br/>= /campanha/meus-contatos]
P --> CH[campaignPageChrome<br/>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).

View file

@ -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`,

View file

@ -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)) {

View file

@ -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 },

View file

@ -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),

View file

@ -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

View file

@ -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)
}

View file

@ -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

View file

@ -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

View file

@ -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<number, ContactVinculo[]>
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<number>,
): Promise<ContactVinculosByID> => {
const byID = new Map<number, Set<ContactVinculo>>()
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<number, ContactVinculo[]>()
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<ContactVinculo> => 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<ContactGender>(state.genders ?? [])
const states = new Set<ContactStateKey>(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<string, string>()
for (const city of state.cities ?? []) {
cities.set(city.toLowerCase(), city)
}
const vinculos = new Set<ContactVinculo>(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 }
}

View file

@ -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<ContactGender, string> = {
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<ContactAbsence, string> = {
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<ContactVinculo, string> = {
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<ContactListSortKey, string> = {
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<string>(contactListParamNames)
const contactGenderSet = new Set<string>(leadershipGenders)
const contactStateSet = new Set<string>(Object.keys(CitiesByState))
const contactAbsenceSet = new Set<string>(CONTACT_ABSENCES)
const contactVinculoSet = new Set<string>(CONTACT_VINCULOS)
const contactListSortKeySet = new Set<string>(Object.keys(contactListSortLabels))
const contactListSortDirSet = new Set<ContactListSortDirection>(['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<ContactListState, ContactListSortKey>({
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 } },
],
}
}

View file

@ -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<PayloadRequest, 'transactionID'>
/**
* 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<Payload, 'db' | 'find'>,
req: ContactNameRequest,
name: string,
contactID?: number,
): Promise<void> => {
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)
}

View file

@ -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"]')

View file

@ -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()

View file

@ -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 ({

View file

@ -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)
})
})

View file

@ -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"')
})

View file

@ -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',

View file

@ -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.',
})
})
})

View file

@ -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)', () => {

View file

@ -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> = {}): 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>): 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)
})
})

View file

@ -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' } },
],
})
})
})

View file

@ -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()
})
})