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