Merge pull request #560 from fsolla/B190-sollinha-municipios-sem-dobradinha-dobradinhas-orfas-co

B190 — Sollinha: municípios sem dobradinha + dobradinhas órfãs (cobertura de parcerias)
This commit is contained in:
Francisco Solla 2026-08-10 07:35:01 -03:00 committed by GitHub
commit 733b019f2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 820 additions and 1 deletions

View file

@ -0,0 +1,78 @@
# Impl: Sollinha: municípios sem dobradinha + dobradinhas órfãs (cobertura de parcerias)
Status: aprovado
Atualizado em: 2026-08-10
Issue: #527
Intenção: docs/plans/sollinha-municipios-sem-dobradinha.md
Appetite restante: ~0,5–1 dia eng (herdado)
## Leitura da intenção
- **Outcome:** o Sollinha responde, em linguagem natural, (1) quais municípios do escopo do usuário não têm nenhuma dobradinha vinculada — com contagem e itens por município — e (2) quais dobradinhas estão órfãs (sem nenhum município vinculado), com contagem e links. Salvador agrupada por cidade por padrão, com opção de detalhar por ZE. Assessor vê só o próprio escopo; leader recebe acesso negado.
- **O que NÃO negociar:** fail-closed para leader (sem dados); leitura relativa ao escopo (assessor = municípios que administra); sem ranking/qualificação de dobradinhas; sem sugestão de deputados-alvo; sem tocar dados eleitorais (lockdown B180); resposta declara que a leitura é sobre o cadastro atual.
- **O que reavaliar:** a hipótese de "nova tool registrada no index" está certa; o diabo está em como computar "órfã" respeitando escopo e nos links para agir (a intenção diz "precedente B162").
## Abordagem recomendada
```mermaid
flowchart LR
A[Pergunta] --> B[getPartnershipCoverage]
B --> C{mode}
C -->|municipios_sem_dobradinha| D[find municipality onde stateDeputies exists:false + RBAC]
D --> E[groupByCity / expandZones + links]
C -->|dobradinhas_orfas| F[read stateDeputy staff-scoped]
F --> G[municipalityIdsByStateDeputyIds → órfãs]
G --> H[lista + links]
```
**Opções consideradas:** A (uma tool, dois modos — recomendada) | B (duas tools) | C (sem link de lista filtrada)
**Recomendação:** A — a intenção fixou "mesma tool, dois modos de saída" e B185 (irmão, mesmo ciclo) segue o mesmo formato; um registro no `index.ts`, uma descrição de domínio.
**Rejeitadas:** B porque o modelo escolheria entre duas tools com o mesmo domínio "cobertura de parcerias" sem ganho de clareza, e a família (B185) já consolidou modos numa tool só; C porque "abrir a lista já filtrada" é o link para agir do coordenador — a URL já suporta `stateDeputy=sem_dobradinha` (B176), só falta expô-la na tool de links.
### Componentes / mudanças
- **`getPartnershipCoverage`** (`src/utilities/ai/tools/getPartnershipCoverage.ts`): factory de tool read-only com `mode: 'municipios_sem_dobradinha' | 'dobradinhas_orfas'` e filtros opcionais `region` (território, tolerante a acento) e `city`. Gate fail-closed inline (`isCampaignStaff`), registrada no `index.ts`.
- **`src/lib/partnershipCoverage.ts`** (novo, puro): tipagem das linhas, agrupamento por cidade (`groupByCity`), resolução tolerante de região (`normalizeSearchPhrase` × `bahiaIdentityTerritories`), ordenação região→cidade (pt-BR). Unit-testável sem payload.
- **Modo municípios:** `payload.find` em `municipality` com `where: { stateDeputies: { exists: false } }`, `overrideAccess: false`, `user: ctx.user` (RBAC já entrega o escopo do assessor e vaza zero para leader), select `name/slug/kind/city/region`. Agrupado por cidade por padrão (Salvador = um item "Salvador" com `unidadesSemDobradinha: X` + lista `zonas`), ou por ZE com `expandZones: true`. Ordena região→cidade. Slug do item: `salvador` (cidade virtual, B178) quando agrupado em Salvador; slug do município caso contrário.
- **Modo órfãs:** lê todos os `stateDeputy` (read é staff-wide não-escopado), depois reusa **`municipalityIdsByStateDeputyIds`** (`src/utilities/stateDeputyData.ts`) para obter o mapa deputado→municípios no registro inteiro — órfãs = deputados fora do mapa. O cálculo de órfão é fato de registro, não relativo a escopo: um deputado vinculado a município fora do escopo do assessor NÃO é órfão. O helper já usa o bypass agregado-only (precedente B34, select só `stateDeputies` — vaza apenas IDs que o staff já pode ler).
- **Links (B162 + extensão mínima):** per-item via destinos existentes (`municipality` slug, `dobradinha` slug; `salvador` já aceito como city slug). **Novo:** `municipalityList` ganha `stateDeputies?: Array<number | 'sem_dobradinha'>` em `campaignNavigationUrls.ts` + schema de `buildCampaignLinks.ts` → `/campanha/municipios?stateDeputy=sem_dobradinha` (parâmetro já suportado pela URL layer, B176; sem mudar contrato existente).
- **Migration:** nenhuma. **Access/Consent:** nenhum novo; gate por papel inline na tool (fail-closed). **UI:** Impeccable A — resposta em texto no chat existente.
### Dados → forma
- Lista exaustiva com contagem (cobertura, não top N), critério sempre declarado na resposta ("Sem nenhuma dobradinha vinculada no cadastro atual"); Salvador agregada com drill-down por ZE opcional. Forma em texto/objetos da tool, como a família (`getLeaderships`/`getDobradinhas`); sem % estadual.
## Fases verificáveis
1. **Schema+server** — `src/lib/partnershipCoverage.ts` puro + `getPartnershipCoverage.ts` + registro no `index.ts`; unit tests do puro (agrupamento, região tolerante, órfãs) e lockdown de leader (padrão `electionToolsLockdown.unit.spec.ts`).
2. **Links** — extensão `municipalityList.stateDeputies` em `campaignNavigationUrls.ts` + `buildCampaignLinks.ts` + testes em `campaignNavigationUrls.unit.spec.ts`.
3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push` (PR `--base main`, `Closes #527`, auto-merge).
## Rabbit holes / Não escopo (engenharia)
- SQL custom para órfãs (drizzle/left join) — o helper existente `municipalityIdsByStateDeputyIds` já entrega o mapa com o bypass agregado-only; N+1 evitado.
- Novo `campaignDataGate.ts` compartilhado — B185 está em paralelo e pode criar; gate inline na tool não colide e vira adoção trivial no /simplify se B185 pousar um.
- Paginação/`limit` na resposta — cobertura é exaustiva; a tool devolve total + itens ordenados, o modelo resume.
- Qualquer leitura eleitoral, ranking ou sugestão de deputado-alvo (anti-goal da intenção; lockdown B180 intacto).
## Riscos e mitigação
- **Órfã com escopo errado (vazamento/inverdade):** computar órfãs dentro do escopo do assessor mostraria deputados vinculados fora do escopo como órfãos. Mitigação: mapa completo via `municipalityIdsByStateDeputyIds` (bypass agregado-only, precedente B34); assertar no teste de lockdown que leader nunca alcança esse read.
- **Região tolerante a acento falha:** "Vale do Jiquiriça" vs "Vale do Jiquiriçá". Mitigação: `normalizeSearchPhrase` dos dois lados contra o catálogo canônico; região não resolvida → `error` com mensagem clara (o modelo re-pergunta).
- **Colisão com B185 (paralelo):** B185 não tem commits à frente de main hoje; mantive a tool auto-contida (sem novo arquivo compartilhado). Se B185 pousar `campaignDataGate`, o /simplify adota.
- **Extensão do link não passar no schema zod:** `z.union` de inteiro + sentinela `'sem_dobradinha'` precisa de `.optional()` e filtro no builder (positivos + sentinela), espelhando o padrão `coverage`.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (dois modos, contagem, Salvador agrupada/drill-down, links, escopo por papel, leader fail-closed)
- [x] Invariantes AGENTS/engineering-standards (identificadores em inglês, pt-BR só em strings de saída; sem migration/Consent; `overrideAccess: false` com user; bypass só no helper B34)
- [x] Testes de domínio: unit do puro (agrupamento/órfãs/região) + lockdown leader + teste de link filtrado
- [x] `pnpm gate:fast` verde; PR em main com `Closes #527`
## Triage do /simplify (2026-08-10)
**Já resolvido no cleanup (não reabrir):** `modo` removido da resposta (eco de `mode` podia falhar schema — `criterio` identifica a leitura); teste de sort com cidade decisiva (Camaçari × Salvador ZE na mesma região); asserts de URL literal em vez de espelhados (`/campanha/municipios?stateDeputy=7&stateDeputy=sem_dobradinha`); stubs de payload com docs populados (agrupado, expandZones, órfãs, região inválida); cast redundante de `zoneNumber`.
**Explicitamente fora:** `total` conta unidades operacionais, não rows agrupados (aceite pede contagem de municípios; `unidades` por row); schemas zod das tools não têm teste direto (família inteira igual — exercitados em E2E/build); guards micro (whitespace, tie-break de nome); filtros `region`/`city`/`expandZones` ignorados no modo órfã (descrições do schema cobrem; refine seria over-engineering).
**Adiado com gatilho:** lookup de nomes de contato duplicado entre `getDobradinhas` e `getPartnershipCoverage` (ids → find → Map, ~15 linhas). **Gatilho:** 3º call site (provável B185/B189) → extrair `loadContactNamesByIds(payload, user, ids)` server-side em `src/utilities/` e adotar nas três tools.

View file

@ -0,0 +1,115 @@
/**
* Pure presentation logic for the Sollinha "cobertura de parcerias" reading
* (B190): which municipalities have no linked dobradinha, and which
* dobradinhas are orphans (no municipality linked anywhere).
*
* Pure module — no server/database imports. The chat tool
* (`src/utilities/ai/tools/getPartnershipCoverage.ts`) queries the
* collections under RBAC and delegates shaping here so the grouping rules are
* unit-testable without payload.
*/
import { bahiaIdentityTerritories, type BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { salvadorCity } from '@/lib/salvadorCity'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
export type PartnershipCoverageMode = 'municipalities' | 'orphanDeputies'
/** One operational municipality without a dobradinha (a ZE of Salvador included). */
export type PartnershipCoverageUnit = {
nome: string
slug: string
cidade: string
regiao: string
/** Zone number for Salvador ZEs; null for whole-municipality units. */
zoneNumber: number | null
}
/** Zone rows the model can link to (`/campanha/municipios/<slug>`). */
type PartnershipCoverageZone = {
nome: string
slug: string
}
/** One row of the grouped (default) reading: a city, with its units folded. */
export type PartnershipCoverageCityRow = {
nome: string
/**
* City page slug when the city is split into zone units (Salvador →
* `salvador`), the single unit's slug otherwise, null when neither exists.
*/
slug: string | null
cidade: string
regiao: string
/** Number of operational units without a dobradinha in this city. */
unidades: number
zonas: PartnershipCoverageZone[]
}
export const PARTNERSHIP_COVERAGE_CRITERION = {
municipalities: 'Municípios sem nenhuma dobradinha vinculada no cadastro atual.',
orphanDeputies: 'Dobradinhas sem nenhum município vinculado no cadastro atual.',
} as const satisfies Record<PartnershipCoverageMode, string>
/**
* Resolves a user-spoken identity territory tolerantly ("Vale do Jiquiriça"
* matches the canonical "Vale do Jiquiriçá") via accent/case-folded equality
* against the 27 canonical names. Returns undefined when nothing matches.
*/
export const resolveIdentityTerritory = (input: string): BahiaIdentityTerritory | undefined => {
const normalized = normalizeSearchPhrase(input)
if (!normalized) return undefined
return bahiaIdentityTerritories.find(
(territory) => normalizeSearchPhrase(territory) === normalized,
)
}
const compareUnits = (left: PartnershipCoverageUnit, right: PartnershipCoverageUnit): number =>
left.regiao.localeCompare(right.regiao, 'pt-BR') ||
left.cidade.localeCompare(right.cidade, 'pt-BR') ||
(left.zoneNumber ?? Number.POSITIVE_INFINITY) - (right.zoneNumber ?? Number.POSITIVE_INFINITY) ||
left.nome.localeCompare(right.nome, 'pt-BR')
/**
* Coverage is a reading of the operational catalog: region asc, then city
* asc, then zone number asc — a stable order the model can echo back.
*/
export const sortCoverageUnits = (
units: readonly PartnershipCoverageUnit[],
): PartnershipCoverageUnit[] => [...units].sort(compareUnits)
/** The city page slug for a split city (Salvador today); null otherwise. */
const cityPageSlug = (cidade: string): string | null =>
cidade === salvadorCity.city ? salvadorCity.slug : null
/**
* Groups units by city, folding a split city (Salvador's ZEs) into one row
* with its zone list. Input is expected sorted by `sortCoverageUnits`, which
* the grouping preserves: zones arrive in zone order, cities in region order.
*/
export const groupCoverageByCity = (
units: readonly PartnershipCoverageUnit[],
): PartnershipCoverageCityRow[] => {
const rows: PartnershipCoverageCityRow[] = []
for (const unit of units) {
const previous = rows[rows.length - 1]
if (previous && previous.cidade === unit.cidade) {
previous.unidades += 1
previous.zonas.push({ nome: unit.nome, slug: unit.slug })
continue
}
rows.push({
nome: unit.cidade,
slug: cityPageSlug(unit.cidade) ?? unit.slug,
cidade: unit.cidade,
regiao: unit.regiao,
unidades: 1,
zonas: [{ nome: unit.nome, slug: unit.slug }],
})
}
return rows
}
/** Orphan dobradinhas read alphabetically (pt-BR) — stable for the model. */
export const sortOrphanDobradinhas = <T extends { nome: string }>(rows: readonly T[]): T[] =>
[...rows].sort((left, right) => left.nome.localeCompare(right.nome, 'pt-BR'))

View file

@ -50,6 +50,7 @@ import {
import {
buildMunicipalityListHref,
municipalityListLevelFilterValues,
NO_STATE_DEPUTY_FILTER_VALUE,
type MunicipalityListLevelFilterValue,
} from '@/utilities/municipality/municipalityListUrl'
import type { MunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
@ -148,6 +149,8 @@ export type CampaignNavigationLinkRequest =
trends?: PoliticalTrendStatus[]
classes?: MunicipalityTerritorialClass[]
levels?: MunicipalityListLevelFilterValue[]
/** B190 — dobradinha ids to filter by, plus the "Sem dobradinha" sentinel. */
stateDeputies?: Array<number | typeof NO_STATE_DEPUTY_FILTER_VALUE>
})
| (BaseLinkFields & {
destination: 'leadershipList'
@ -411,6 +414,11 @@ const buildPathForRequest = (
const levels = request.levels?.filter((level): level is MunicipalityListLevelFilterValue =>
municipalityLevelFilterSet.has(level),
)
const stateDeputies = request.stateDeputies?.filter((value) =>
typeof value === 'number'
? positiveInt(value, 'stateDeputy') !== null
: value === NO_STATE_DEPUTY_FILTER_VALUE,
)
const path = buildMunicipalityListHref(
{
page: 1,
@ -423,6 +431,7 @@ const buildPathForRequest = (
...(trends?.length ? { trends } : {}),
...(classes?.length ? { classes } : {}),
...(levels?.length ? { levels } : {}),
...(stateDeputies?.length ? { stateDeputies } : {}),
},
1,
)

View file

@ -16,7 +16,10 @@ import { activityTabs } from '@/utilities/activityUi'
import type { CampaignNavigationLinkRequest } from '@/utilities/ai/campaignNavigationUrls'
import { buildCampaignNavigationLinks } from '@/utilities/ai/campaignNavigationUrls'
import { politicalTrendLabels } from '@/utilities/municipality/municipalityLabels'
import { municipalityListLevelFilterValues } from '@/utilities/municipality/municipalityListUrl'
import {
municipalityListLevelFilterValues,
NO_STATE_DEPUTY_FILTER_VALUE,
} from '@/utilities/municipality/municipalityListUrl'
const labelSchema = z
.string()
@ -122,6 +125,12 @@ const linkRequestSchema = z.discriminatedUnion('destination', [
trends: z.array(z.enum(politicalTrendKeys)).optional(),
classes: z.array(z.enum(TERRITORIAL_CLASSES)).optional(),
levels: z.array(z.enum(municipalityListLevelFilterValues as [string, ...string[]])).optional(),
stateDeputies: z
.array(z.union([z.number().int().positive(), z.literal(NO_STATE_DEPUTY_FILTER_VALUE)]))
.optional()
.describe(
'Dobradinha ids to filter the list by, or the sentinel "sem_dobradinha" for municipalities without any dobradinha.',
),
label: labelSchema,
}),
z.object({

View file

@ -0,0 +1,206 @@
import 'server-only'
import { tool } from 'ai'
import type { Where } from 'payload'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
import {
groupCoverageByCity,
PARTNERSHIP_COVERAGE_CRITERION,
resolveIdentityTerritory,
sortCoverageUnits,
sortOrphanDobradinhas,
type PartnershipCoverageUnit,
} from '@/lib/partnershipCoverage'
import { populatedContactName, relationshipId } from '@/lib/relationship'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { municipalityIdsByStateDeputyIds } from '@/utilities/stateDeputyData'
const COVERAGE_DENIED = { error: 'Leitura de dados da campanha negada.' }
export const getPartnershipCoverage = (ctx: AIToolContext) =>
tool({
description:
'Returns partnership (dobradinha) coverage for the campaign: which municipalities have NO ' +
'linked dobradinha ("quais municípios ainda não têm dobradinha?"), or which dobradinhas are ' +
'orphans with no municipality linked ("quais dobradinhas estão sem município?"). ' +
'The reading is about the current registry, not about which deputy to approach. ' +
'Salvador is grouped by city by default (its 19 zones as one unit); pass expandZones to list zones individually.',
inputSchema: z.object({
mode: z
.enum(['municipalities', 'orphanDeputies'])
.describe(
'municipalities = municipalities in the user scope without any linked dobradinha; ' +
'orphanDeputies = dobradinhas with no municipality linked anywhere in the registry.',
),
region: z
.string()
.optional()
.describe(
'Optional identity territory filter (e.g. "Vale do Jiquiriçá"). Only for mode municipalities.',
),
city: z
.string()
.optional()
.describe(
'Optional city filter (e.g. "Salvador" matches its 19 zones). Only for mode municipalities.',
),
expandZones: z
.boolean()
.optional()
.describe(
'List Salvador zone municipalities individually instead of grouping them under the city (default false).',
),
}),
execute: async ({ mode, region, city, expandZones }) => {
const { payload, user } = ctx
// Fail-closed: leaders never reach the coverage reading (the orphan
// side reads the whole registry through an aggregate bypass).
if (!isCampaignStaff(user)) return COVERAGE_DENIED
if (mode === 'orphanDeputies') {
return loadOrphanDobradinhas(payload, user)
}
const andClauses: Where[] = [{ stateDeputies: { exists: false } }]
if (region) {
const territory = resolveIdentityTerritory(region)
if (!territory) {
return {
error: `Região não reconhecida: "${region}". Use o nome de um território de identidade da Bahia (ex.: "Vale do Jiquiriçá").`,
}
}
andClauses.push({ region: { equals: territory } })
}
if (city) {
andClauses.push({ city: { like: city } })
}
const result = await payload.find({
collection: 'municipality',
where: { and: andClauses },
depth: 0,
limit: 0,
pagination: false,
select: { name: true, slug: true, city: true, region: true, zoneNumber: true },
overrideAccess: false,
user,
})
const units: PartnershipCoverageUnit[] = (result.docs as Array<Record<string, unknown>>).map(
(doc) => ({
nome: doc.name as string,
slug: doc.slug as string,
cidade: doc.city as string,
regiao: doc.region as string,
zoneNumber: typeof doc.zoneNumber === 'number' ? doc.zoneNumber : null,
}),
)
const ordered = sortCoverageUnits(units)
if (ordered.length === 0) {
return {
message: 'Nenhum município sem dobradinha no escopo atual.',
criterio: PARTNERSHIP_COVERAGE_CRITERION.municipalities,
}
}
if (expandZones) {
return {
criterio: PARTNERSHIP_COVERAGE_CRITERION.municipalities,
agrupadoPorCidade: false,
total: ordered.length,
municipios: ordered.map((unit) => ({
nome: unit.nome,
slug: unit.slug,
cidade: unit.cidade,
regiao: unit.regiao,
})),
}
}
const rows = groupCoverageByCity(ordered)
return {
criterio: PARTNERSHIP_COVERAGE_CRITERION.municipalities,
agrupadoPorCidade: true,
total: ordered.length,
municipios: rows,
}
},
})
/**
* Orphans are a registry-level fact, not a scope-relative one: a dobradinha
* linked to municipalities outside the advisor's scope is NOT an orphan. The
* linked set therefore comes from `municipalityIdsByStateDeputyIds` (aggregate
* bypass, B34 precedent — reads only `stateDeputies` ids), and the deputy
* rows themselves stay under the staff read access.
*/
const loadOrphanDobradinhas = async (
payload: AIToolContext['payload'],
user: AIToolContext['user'],
) => {
const deputies = await payload.find({
collection: 'stateDeputy',
where: {},
depth: 0,
limit: 0,
pagination: false,
select: { contact: true, slug: true, party: true, notes: true },
overrideAccess: false,
user,
})
const stateDeputyIDs = deputies.docs.map((doc) => doc.id)
const municipalitiesByDeputy = await municipalityIdsByStateDeputyIds(payload, stateDeputyIDs)
const contactIDs = deputies.docs
.map((doc) => relationshipId(doc.contact))
.filter((id): id is number => id !== null)
const contactNames = new Map<number, string>()
if (contactIDs.length > 0) {
const contacts = await payload.find({
collection: 'contact',
where: { id: { in: contactIDs } },
depth: 0,
limit: 0,
pagination: false,
select: { name: true },
overrideAccess: false,
user,
})
for (const contact of contacts.docs) contactNames.set(contact.id, contact.name)
}
const orphans = sortOrphanDobradinhas(
deputies.docs
.filter((doc) => !municipalitiesByDeputy.has(doc.id))
.map((doc) => ({
nome: populatedContactName(
doc.contact,
contactNames.get(relationshipId(doc.contact) ?? -1) ?? 'Contato',
),
slug: doc.slug,
partido: doc.party ?? null,
observacoes: doc.notes ?? null,
})),
)
if (orphans.length === 0) {
return {
message: 'Nenhuma dobradinha órfã cadastrada.',
criterio: PARTNERSHIP_COVERAGE_CRITERION.orphanDeputies,
}
}
return {
criterio: PARTNERSHIP_COVERAGE_CRITERION.orphanDeputies,
total: orphans.length,
dobradinhas: orphans,
}
}

View file

@ -15,6 +15,7 @@ import { getLeadingMunicipalities } from '@/utilities/ai/tools/getLeadingMunicip
import { getMunicipalityOverview } from '@/utilities/ai/tools/getMunicipalityOverview'
import { getMunicipalityVotes } from '@/utilities/ai/tools/getMunicipalityVotes'
import { getOrganizations } from '@/utilities/ai/tools/getOrganizations'
import { getPartnershipCoverage } from '@/utilities/ai/tools/getPartnershipCoverage'
import { getPendingLeaderships } from '@/utilities/ai/tools/getPendingLeaderships'
import { getTopDeputies } from '@/utilities/ai/tools/getTopDeputies'
import { searchEntities } from '@/utilities/ai/tools/searchEntities'
@ -30,5 +31,6 @@ export const buildAITools = (ctx: AIToolContext) => ({
getLeaderships: getLeaderships(ctx),
getPendingLeaderships: getPendingLeaderships(ctx),
getOrganizations: getOrganizations(ctx),
getPartnershipCoverage: getPartnershipCoverage(ctx),
searchEntities: searchEntities(ctx),
})

View file

@ -74,6 +74,31 @@ describe('campaignNavigationUrls', () => {
}
})
it('builds the "Sem dobradinha" municipality list filter (B190)', () => {
const outcome = buildCampaignNavigationLink(coordinator.role, {
destination: 'municipalityList',
stateDeputies: ['sem_dobradinha', 7],
label: 'Municípios sem dobradinha',
})
expect(outcome.ok).toBe(true)
if (outcome.ok) {
// Literal URL — canonical relationship params: ids asc, sentinel last.
expect(outcome.path).toBe('/campanha/municipios?stateDeputy=7&stateDeputy=sem_dobradinha')
expect(outcome.label).toBe('Municípios sem dobradinha')
}
})
it('drops invalid stateDeputy filter values instead of failing the link', () => {
const outcome = buildCampaignNavigationLink(coordinator.role, {
destination: 'municipalityList',
stateDeputies: ['sem_dobradinha', -3],
})
expect(outcome.ok).toBe(true)
if (outcome.ok) {
expect(outcome.path).toBe('/campanha/municipios?stateDeputy=sem_dobradinha')
}
})
it('blocks staff destinations for leaders', () => {
const outcome = buildCampaignNavigationLink(leader.role, {
destination: 'municipality',

View file

@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import {
groupCoverageByCity,
PARTNERSHIP_COVERAGE_CRITERION,
resolveIdentityTerritory,
sortCoverageUnits,
sortOrphanDobradinhas,
type PartnershipCoverageUnit,
} from '@/lib/partnershipCoverage'
const unit = (
overrides: Partial<PartnershipCoverageUnit> & Pick<PartnershipCoverageUnit, 'nome' | 'slug'>,
): PartnershipCoverageUnit => ({
cidade: 'Feira de Santana',
regiao: 'Portal do Sertão',
zoneNumber: null,
...overrides,
})
describe('resolveIdentityTerritory', () => {
it('resolves a canonical territory name', () => {
expect(resolveIdentityTerritory('Vale do Jiquiriçá')).toBe('Vale do Jiquiriçá')
})
it('is tolerant to missing accents and case (B185 family semantics)', () => {
expect(resolveIdentityTerritory('vale do jiquirica')).toBe('Vale do Jiquiriçá')
})
it('returns undefined for an unknown name', () => {
expect(resolveIdentityTerritory('Planeta Marte')).toBeUndefined()
})
})
describe('sortCoverageUnits', () => {
it('orders by region, then city, then zone number', () => {
const units = sortCoverageUnits([
unit({
nome: 'Salvador — ZE 10',
slug: 'salvador-ze-10',
cidade: 'Salvador',
regiao: 'Metropolitano de Salvador',
zoneNumber: 10,
}),
unit({
nome: 'Salvador — ZE 2',
slug: 'salvador-ze-2',
cidade: 'Salvador',
regiao: 'Metropolitano de Salvador',
zoneNumber: 2,
}),
unit({
nome: 'Camaçari',
slug: 'camacari',
cidade: 'Camaçari',
regiao: 'Metropolitano de Salvador',
}),
unit({ nome: 'Abaré', slug: 'abare', cidade: 'Abaré', regiao: 'Sertão do São Francisco' }),
])
// Same region: city asc decides (Camaçari before Salvador), then zone asc.
expect(units.map((entry) => entry.slug)).toEqual([
'camacari',
'salvador-ze-2',
'salvador-ze-10',
'abare',
])
})
it('sorts whole municipalities of a city after its zones', () => {
const units = sortCoverageUnits([
unit({ nome: 'Salvador — ZE 1', slug: 'salvador-ze-1', cidade: 'Salvador', zoneNumber: 1 }),
unit({ nome: 'Salvador', slug: 'salvador', cidade: 'Salvador', zoneNumber: null }),
])
expect(units.map((entry) => entry.slug)).toEqual(['salvador-ze-1', 'salvador'])
})
})
describe('groupCoverageByCity', () => {
it('folds Salvador zones into one city row with the city slug (B178)', () => {
const rows = groupCoverageByCity(
sortCoverageUnits([
unit({ nome: 'Salvador — ZE 1', slug: 'salvador-ze-1', cidade: 'Salvador', zoneNumber: 1 }),
unit({ nome: 'Salvador — ZE 3', slug: 'salvador-ze-3', cidade: 'Salvador', zoneNumber: 3 }),
]),
)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
nome: 'Salvador',
slug: 'salvador',
cidade: 'Salvador',
unidades: 2,
})
expect(rows[0]!.zonas.map((zona) => zona.slug)).toEqual(['salvador-ze-1', 'salvador-ze-3'])
})
it('keeps whole-municipality cities as one row with their own slug', () => {
const rows = groupCoverageByCity(
sortCoverageUnits([unit({ nome: 'Ilhéus', slug: 'ilheus', cidade: 'Ilhéus' })]),
)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ nome: 'Ilhéus', slug: 'ilheus', unidades: 1 })
expect(rows[0]!.zonas).toEqual([{ nome: 'Ilhéus', slug: 'ilheus' }])
})
it('preserves region/city ordering across rows', () => {
const rows = groupCoverageByCity(
sortCoverageUnits([
unit({ nome: 'Abaré', slug: 'abare', cidade: 'Abaré', regiao: 'Sertão do São Francisco' }),
unit({ nome: 'Salvador — ZE 1', slug: 'salvador-ze-1', cidade: 'Salvador', zoneNumber: 1 }),
unit({
nome: 'Camaçari',
slug: 'camacari',
cidade: 'Camaçari',
regiao: 'Metropolitano de Salvador',
}),
]),
)
expect(rows.map((row) => row.cidade)).toEqual(['Camaçari', 'Salvador', 'Abaré'])
})
})
describe('sortOrphanDobradinhas', () => {
it('sorts orphans by name in pt-BR', () => {
const rows = sortOrphanDobradinhas([
{ nome: 'Zé', slug: 'ze' },
{ nome: 'Ana', slug: 'ana' },
{ nome: 'Álvaro', slug: 'alvaro' },
])
expect(rows.map((row) => row.nome)).toEqual(['Álvaro', 'Ana', 'Zé'])
})
})
describe('PARTNERSHIP_COVERAGE_CRITERION', () => {
it('declares the registry-scope criterion for both modes', () => {
expect(PARTNERSHIP_COVERAGE_CRITERION.municipalities).toMatch(/cadastro atual/)
expect(PARTNERSHIP_COVERAGE_CRITERION.orphanDeputies).toMatch(/cadastro atual/)
})
})

View file

@ -0,0 +1,231 @@
import type { Payload } from 'payload'
import { describe, expect, it } from 'vitest'
import type { AIToolContext } from '@/lib/ai/types'
import type { CampaignUser } from '@/payload-types'
import { getPartnershipCoverage } from '@/utilities/ai/tools/getPartnershipCoverage'
import { stub } from '../helpers/stub'
const denied = { error: 'Leitura de dados da campanha negada.' }
/** A payload whose find throws — any query after a failed gate is a bug. */
const untouchablePayload = stub<Payload>({
find: () => {
throw new Error('gate must fail closed before any payload query')
},
})
const ctxFor = (user: CampaignUser, payload: Payload): AIToolContext => ({ user, payload })
const leader = stub<CampaignUser>({ collection: 'campaignUser', role: 'leader' })
const coordinator = stub<CampaignUser>({ collection: 'campaignUser', role: 'coordinator' })
type ExecutableTool = {
execute: (args: unknown, options?: unknown) => Promise<unknown>
}
const executeFor = (user: CampaignUser, payload: Payload) => {
const execute = (getPartnershipCoverage(ctxFor(user, payload)) as unknown as ExecutableTool)
.execute
return (args: unknown) => execute(args)
}
const emptyDocsPayload = stub<Payload>({
find: async () => ({
docs: [],
hasNextPage: false,
hasPrevPage: false,
limit: 0,
nextPage: null,
pagingCounter: 0,
prevPage: null,
totalDocs: 0,
totalPages: 0,
}),
})
/**
* Stub payload dispatching by collection: municipality rows (coverage read),
* municipality rows carrying only `stateDeputies` (orphan aggregate), and
* stateDeputy/contact docs for the orphan mode. The generic `Payload['find']`
* cannot express arbitrary populated docs, so the mock is cast at the call
* site — the same tradeoff `stub` already makes for completeness.
*/
const coveragePayload = (overrides: {
municipalities?: Array<Record<string, unknown>>
municipalityStateDeputies?: number[]
stateDeputies?: Array<Record<string, unknown>>
contacts?: Array<Record<string, unknown>>
}): Payload => {
const find = (async ({ collection }: { collection?: string }) => {
const docs =
collection === 'municipality' && overrides.municipalityStateDeputies
? overrides.municipalityStateDeputies.map((id) => ({ id, stateDeputies: [id] }))
: collection === 'municipality'
? (overrides.municipalities ?? [])
: collection === 'stateDeputy'
? (overrides.stateDeputies ?? [])
: (overrides.contacts ?? [])
return {
docs,
hasNextPage: false,
hasPrevPage: false,
limit: 0,
nextPage: null,
pagingCounter: 0,
prevPage: null,
totalDocs: 0,
totalPages: 0,
}
}) as unknown as Payload['find']
return stub<Payload>({ find })
}
describe('partnership coverage tools leader lockdown (B190)', () => {
it('getPartnershipCoverage (municipalities) fails closed before any payload query', async () => {
await expect(
executeFor(leader, untouchablePayload)({ mode: 'municipalities' }),
).resolves.toEqual(denied)
})
it('getPartnershipCoverage (orphanDeputies) fails closed before any payload query', async () => {
await expect(
executeFor(leader, untouchablePayload)({ mode: 'orphanDeputies' }),
).resolves.toEqual(denied)
})
it('lets staff pass the gate (queries reach payload)', async () => {
const result = await executeFor(coordinator, emptyDocsPayload)({ mode: 'municipalities' })
expect(result).toMatchObject({ message: 'Nenhum município sem dobradinha no escopo atual.' })
})
})
describe('partnership coverage tool staff behavior (B190)', () => {
it('returns empty municipalities with the declared criterion', async () => {
const result = await executeFor(coordinator, emptyDocsPayload)({ mode: 'municipalities' })
expect(result).toMatchObject({
message: 'Nenhum município sem dobradinha no escopo atual.',
criterio: expect.stringContaining('cadastro atual'),
})
})
it('groups Salvador zones under the city by default', async () => {
const payload = coveragePayload({
municipalities: [
{
name: 'Salvador — ZE 1',
slug: 'salvador-ze-1',
city: 'Salvador',
region: 'Metropolitano de Salvador',
zoneNumber: 1,
},
{
name: 'Salvador — ZE 3',
slug: 'salvador-ze-3',
city: 'Salvador',
region: 'Metropolitano de Salvador',
zoneNumber: 3,
},
{ name: 'Ilhéus', slug: 'ilheus', city: 'Ilhéus', region: 'Litoral Sul', zoneNumber: null },
],
})
const result = await executeFor(coordinator, payload)({ mode: 'municipalities' })
expect(result).toMatchObject({
agrupadoPorCidade: true,
total: 3,
municipios: [
{ nome: 'Ilhéus', slug: 'ilheus', unidades: 1 },
{ nome: 'Salvador', slug: 'salvador', unidades: 2 },
],
})
})
it('expands Salvador zones when expandZones is true', async () => {
const payload = coveragePayload({
municipalities: [
{
name: 'Salvador — ZE 1',
slug: 'salvador-ze-1',
city: 'Salvador',
region: 'Metropolitano de Salvador',
zoneNumber: 1,
},
{
name: 'Salvador — ZE 3',
slug: 'salvador-ze-3',
city: 'Salvador',
region: 'Metropolitano de Salvador',
zoneNumber: 3,
},
],
})
const result = await executeFor(
coordinator,
payload,
)({
mode: 'municipalities',
expandZones: true,
})
expect(result).toMatchObject({
agrupadoPorCidade: false,
total: 2,
municipios: [
{ nome: 'Salvador — ZE 1', slug: 'salvador-ze-1' },
{ nome: 'Salvador — ZE 3', slug: 'salvador-ze-3' },
],
})
})
it('rejects an unrecognized region with a helpful error', async () => {
const result = await executeFor(
coordinator,
emptyDocsPayload,
)({
mode: 'municipalities',
region: 'Planeta Marte',
})
expect(result).toMatchObject({ error: expect.stringContaining('Região não reconhecida') })
})
it('returns orphans only for deputies with no municipality anywhere', async () => {
const payload = coveragePayload({
// Municipality 1 links deputy 10; municipality 2 links deputies 10 and 20.
municipalityStateDeputies: [10, 20],
stateDeputies: [
{ id: 10, slug: 'maria', party: 'PT', notes: null, contact: 100 },
{ id: 20, slug: 'joao', party: 'PSB', notes: null, contact: 200 },
{ id: 30, slug: 'ana', party: 'PCdoB', notes: null, contact: 300 },
],
contacts: [
{ id: 100, name: 'Maria' },
{ id: 200, name: 'João' },
{ id: 300, name: 'Ana' },
],
})
const result = await executeFor(coordinator, payload)({ mode: 'orphanDeputies' })
expect(result).toMatchObject({
criterio: expect.stringContaining('cadastro atual'),
total: 1,
dobradinhas: [{ nome: 'Ana', slug: 'ana', partido: 'PCdoB' }],
})
})
it('reports when no orphan dobradinhas exist', async () => {
const payload = coveragePayload({
municipalityStateDeputies: [10],
stateDeputies: [{ id: 10, slug: 'maria', party: 'PT', notes: null, contact: 100 }],
contacts: [{ id: 100, name: 'Maria' }],
})
const result = await executeFor(coordinator, payload)({ mode: 'orphanDeputies' })
expect(result).toMatchObject({
message: 'Nenhuma dobradinha órfã cadastrada.',
criterio: expect.stringContaining('cadastro atual'),
})
})
})