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:
parent
85e5aa81e1
commit
1daaaf9f4e
23 changed files with 1470 additions and 28 deletions
252
docs/plans/contatos-pagina-da-entidade-contact-impl.md
Normal file
252
docs/plans/contatos-pagina-da-entidade-contact-impl.md
Normal 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).
|
||||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
312
src/utilities/contacts/contactListData.ts
Normal file
312
src/utilities/contacts/contactListData.ts
Normal 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 }
|
||||
}
|
||||
263
src/utilities/contacts/contactListUrl.ts
Normal file
263
src/utilities/contacts/contactListUrl.ts
Normal 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 } },
|
||||
],
|
||||
}
|
||||
}
|
||||
57
src/utilities/contacts/contactNameInvariant.ts
Normal file
57
src/utilities/contacts/contactNameInvariant.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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"]')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 ({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
234
tests/unit/contactListData.unit.spec.ts
Normal file
234
tests/unit/contactListData.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
206
tests/unit/contactListUrl.unit.spec.ts
Normal file
206
tests/unit/contactListUrl.unit.spec.ts
Normal 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' } },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
63
tests/unit/contactNameInvariant.unit.spec.ts
Normal file
63
tests/unit/contactNameInvariant.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue