From 437359919297f6579b08d1a3f15dd4601d3d79c2 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 07:23:51 -0300 Subject: [PATCH] =?UTF-8?q?feat(B190):=20Sollinha=20=E2=80=94=20cobertura?= =?UTF-8?q?=20de=20parcerias=20(munic=C3=ADpios=20sem=20dobradinha=20+=20d?= =?UTF-8?q?obradinhas=20=C3=B3rf=C3=A3s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nova tool getPartnershipCoverage com dois modos: municípios do escopo sem nenhuma dobradinha vinculada (Salvador agrupada por cidade, drill-down por ZE) e dobradinhas órfãs (sem município vinculado no registro inteiro, via bypass agregado-only do helper B34). Gate fail-closed para leader; região tolerante a acento; critério declarado na resposta. buildCampaignLinks ganha o filtro stateDeputies sem_dobradinha na lista de municípios (URL B176). Closes #527 --- ...sollinha-municipios-sem-dobradinha-impl.md | 78 ++++++ src/lib/partnershipCoverage.ts | 115 +++++++++ src/utilities/ai/campaignNavigationUrls.ts | 9 + src/utilities/ai/tools/buildCampaignLinks.ts | 11 +- .../ai/tools/getPartnershipCoverage.ts | 206 ++++++++++++++++ src/utilities/ai/tools/index.ts | 2 + .../unit/campaignNavigationUrls.unit.spec.ts | 25 ++ tests/unit/partnershipCoverage.unit.spec.ts | 144 +++++++++++ .../partnershipCoverageLockdown.unit.spec.ts | 231 ++++++++++++++++++ 9 files changed, 820 insertions(+), 1 deletion(-) create mode 100644 docs/plans/sollinha-municipios-sem-dobradinha-impl.md create mode 100644 src/lib/partnershipCoverage.ts create mode 100644 src/utilities/ai/tools/getPartnershipCoverage.ts create mode 100644 tests/unit/partnershipCoverage.unit.spec.ts create mode 100644 tests/unit/partnershipCoverageLockdown.unit.spec.ts diff --git a/docs/plans/sollinha-municipios-sem-dobradinha-impl.md b/docs/plans/sollinha-municipios-sem-dobradinha-impl.md new file mode 100644 index 00000000..5d88be8b --- /dev/null +++ b/docs/plans/sollinha-municipios-sem-dobradinha-impl.md @@ -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` 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. diff --git a/src/lib/partnershipCoverage.ts b/src/lib/partnershipCoverage.ts new file mode 100644 index 00000000..995323fa --- /dev/null +++ b/src/lib/partnershipCoverage.ts @@ -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/`). */ +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 + +/** + * 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 = (rows: readonly T[]): T[] => + [...rows].sort((left, right) => left.nome.localeCompare(right.nome, 'pt-BR')) diff --git a/src/utilities/ai/campaignNavigationUrls.ts b/src/utilities/ai/campaignNavigationUrls.ts index bcab9b85..6b822ca5 100644 --- a/src/utilities/ai/campaignNavigationUrls.ts +++ b/src/utilities/ai/campaignNavigationUrls.ts @@ -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 }) | (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, ) diff --git a/src/utilities/ai/tools/buildCampaignLinks.ts b/src/utilities/ai/tools/buildCampaignLinks.ts index 383c7733..83dc7491 100644 --- a/src/utilities/ai/tools/buildCampaignLinks.ts +++ b/src/utilities/ai/tools/buildCampaignLinks.ts @@ -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({ diff --git a/src/utilities/ai/tools/getPartnershipCoverage.ts b/src/utilities/ai/tools/getPartnershipCoverage.ts new file mode 100644 index 00000000..c261368e --- /dev/null +++ b/src/utilities/ai/tools/getPartnershipCoverage.ts @@ -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>).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() + 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, + } +} diff --git a/src/utilities/ai/tools/index.ts b/src/utilities/ai/tools/index.ts index b9a154d2..029e4fd3 100644 --- a/src/utilities/ai/tools/index.ts +++ b/src/utilities/ai/tools/index.ts @@ -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), }) diff --git a/tests/unit/campaignNavigationUrls.unit.spec.ts b/tests/unit/campaignNavigationUrls.unit.spec.ts index 10c35303..65059825 100644 --- a/tests/unit/campaignNavigationUrls.unit.spec.ts +++ b/tests/unit/campaignNavigationUrls.unit.spec.ts @@ -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', diff --git a/tests/unit/partnershipCoverage.unit.spec.ts b/tests/unit/partnershipCoverage.unit.spec.ts new file mode 100644 index 00000000..3dec121b --- /dev/null +++ b/tests/unit/partnershipCoverage.unit.spec.ts @@ -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 & Pick, +): 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/) + }) +}) diff --git a/tests/unit/partnershipCoverageLockdown.unit.spec.ts b/tests/unit/partnershipCoverageLockdown.unit.spec.ts new file mode 100644 index 00000000..e306e2f9 --- /dev/null +++ b/tests/unit/partnershipCoverageLockdown.unit.spec.ts @@ -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({ + find: () => { + throw new Error('gate must fail closed before any payload query') + }, +}) + +const ctxFor = (user: CampaignUser, payload: Payload): AIToolContext => ({ user, payload }) + +const leader = stub({ collection: 'campaignUser', role: 'leader' }) +const coordinator = stub({ collection: 'campaignUser', role: 'coordinator' }) + +type ExecutableTool = { + execute: (args: unknown, options?: unknown) => Promise +} + +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({ + 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> + municipalityStateDeputies?: number[] + stateDeputies?: Array> + contacts?: Array> +}): 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({ 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'), + }) + }) +})