mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Merge pull request #551 from fsolla/B185-sollinha-liderancas-pendentes-de-abordagem-assessores-r
B185 — revisão /simplify: escopo efetivo de assessor + DRY na tool de lideranças pendentes
This commit is contained in:
commit
f38ae4d63f
4 changed files with 261 additions and 182 deletions
|
|
@ -6,6 +6,27 @@ Issue: #524
|
|||
Intenção: docs/plans/sollinha-liderancas-pendentes-abordagem.md
|
||||
Appetite restante: ~1 dia eng (herdado); sem migration / Consent / collection / UI nova
|
||||
|
||||
## Registro de execução (2026-08-10)
|
||||
|
||||
- **Entrega:** `getPendingLeaderships` nova (`src/utilities/ai/tools/getPendingLeaderships.ts`) + registro no `index.ts` + seção no system prompt; 18 unit tests (`tests/unit/pendingLeadershipsTool.unit.spec.ts`); allowlist P3-D justificada. Mergeado via PR #547 (Closes #524); Issue fechada `done/in-prod`.
|
||||
- **Divergência da hipótese da intenção:** nenhuma nos aceites. O corte de `negativo` (fora do critério declarado) foi apresentado no gate humano e aprovado. Implementado como previsto: critério default fixo com eixo do compromisso = presença de `votePledge` no escopo; recência/`declaredAt` deixada para a B186.
|
||||
- **Pós-merge, revisão /simplify (3 reviewers paralelos) — fixes aplicados em PR de follow-up:**
|
||||
- Escopo efetivo para assessor: município/território/cidade real **fora do portfólio** devolve lista vazia escopada (antes: erro "não reconhecido" — mentira de existência); resposta agora declara `escopoRestrito: true` para advisor (o plano prometia "a resposta declara o escopo efetivo" e o teste do shape vazio — ambos faltavam).
|
||||
- Chips de município fora do acesso: removidos (antes `{ id, nome: null }` vazava o vínculo — mesma política da UI `MunicipalityPortfolioCell`).
|
||||
- `uniqueIDs` local substituído pelo canônico `uniqueRelationshipIds` (regra "edit the owner, don't twin").
|
||||
- Chaves do input zod em inglês (`scope`/`filter`/`mode` — convenção de identificadores; valores continuam pt como dados).
|
||||
- Salvador: ramo da cidade antes do ramo de município (elimina o round-trip garantido de miss).
|
||||
- Query de pledges restrita às lideranças `engajado` (a_abordar/em_disputa nunca consultam o eixo) + guard de skip.
|
||||
- `dica` de estreitar escopo quando `truncado` + linha no prompt sobre `escopoRestrito`/`truncado`.
|
||||
- Tipo nomeado `PendingLeadershipDoc` no lugar do cast inline.
|
||||
- **`exists: false` em hasMany `advisors`:** verificação obrigatória do plano executada **ao vivo** durante a revisão (reviewer 3, contra a DB local do worktree com linhas reais): o id-set do SQL bate com o filtro JS. Não pinado em int test (tools são unit-stubbed); risco residual zero observado.
|
||||
- **Débitos deferidos (triage da revisão, sem Issue):**
|
||||
- Eixo do compromisso binário por liderança (não por liderança×município): liderança engajada com pledge em 1 de 3 municípios do escopo aparece/não aparece conforme o recorte da pergunta; o critério declarado torna isso transparente. **Gatilho:** se a família B185/B186/B189 precisar de granularidade por município, adicionar flags `semCompromisso` por município na resposta.
|
||||
- `getLeaderships.SUPPORT_LABELS` (duplicata privada de `supportStatusLabels`): **gatilho:** próxima edição de `getLeaderships`.
|
||||
- Mensagem "Escopo não reconhecido" para ZE individual ("Salvador ZE 3"): aceitável (intenção só promete nível cidade), texto permanece.
|
||||
- `loadNamesByIds` ao lado dos helpers P3-E (justificado: os P3-E usam `overrideAccess: true`; aqui o acesso do ator deve valer). Polish.
|
||||
- **Perda no main (fora desta entrega):** o merge do C104 (PR #537) removeu do `docs/CHANGELOG-AGENTS.md` as entradas B183 e C102 — restauração é decisão de outra Issue (registrada no comentário da #524).
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** uma tool nova do Sollinha responde "quais lideranças ainda precisam ser abordadas" por território de identidade, cidade (Salvador = 19 ZE) ou município — com o **critério de pendência declarado na resposta**, responsáveis (assessores) à vista, links de navegação via `buildCampaignLinks`, RBAC atual (assessor = portfólio; leader = deny fail-closed), filtro "sem assessor" (C3) e modo "municípios sem liderança" (C4).
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp
|
|||
- Use a ferramenta "getPendingLeaderships" para "quais lideranças ainda precisam ser abordadas em X", "lideranças sem assessor responsável" ou "quais municípios não têm liderança cadastrada".
|
||||
- A resposta da ferramenta traz o critério de pendência — declare-o sempre na sua resposta (ex.: "critério: status 'A abordar' ou 'Em disputa'; ou 'Engajado' sem compromisso de votos no escopo consultado").
|
||||
- Escopos: território de identidade (aceita grafia sem acento, ex.: "Vale do Jiquiriça"), cidade ("Salvador" = as 19 zonas eleitorais juntas) ou município. Sem escopo, vale o escopo de acesso do usuário.
|
||||
- Quando a resposta trouxer "escopoRestrito: true", deixe claro que os resultados estão limitados aos municípios do portfólio do usuário; quando trouxer "truncado: true", sugira estreitar o escopo para ver o restante.
|
||||
- Ofereça links de navegação (buildCampaignLinks) para as lideranças citadas (por id) e municípios (por slug); assessores (por id) quando o perfil permitir.
|
||||
|
||||
## Contexto eleitoral
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type { AIToolContext } from '@/lib/ai/types'
|
|||
import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
|
||||
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
||||
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import { salvadorCity } from '@/lib/salvadorCity'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
|
||||
|
|
@ -15,6 +15,7 @@ const DENIED_MESSAGE = 'Leitura de dados de lideranças negada.'
|
|||
const PENDING_CRITERION =
|
||||
'Status "A abordar" ou "Em disputa"; ou "Engajado" sem compromisso de votos no escopo consultado.'
|
||||
const EMPTY_MUNICIPALITIES_CRITERION = 'Municípios do escopo sem nenhuma liderança vinculada.'
|
||||
const NARROW_SCOPE_HINT = 'Estreite o escopo (território, cidade ou município) para ver o restante.'
|
||||
const PENDING_SUPPORT_STATUSES = ['a_abordar', 'em_disputa', 'engajado'] as const
|
||||
|
||||
type ScopeMunicipality = {
|
||||
|
|
@ -32,6 +33,15 @@ type ResolvedScope = {
|
|||
municipalities: ScopeMunicipality[]
|
||||
}
|
||||
|
||||
type PendingLeadershipDoc = {
|
||||
id: number
|
||||
contact: number | string | null
|
||||
municipalities: Array<number | string> | null
|
||||
supportStatus: string
|
||||
advisors: Array<number | string> | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* B185 — "quais lideranças ainda precisam ser abordadas" por território,
|
||||
* cidade (Salvador = 19 ZE) ou município, com critério declarado e assessores
|
||||
|
|
@ -49,7 +59,7 @@ export const getPendingLeaderships = (ctx: AIToolContext) =>
|
|||
'"Quais lideranças estão sem assessor responsável?" or "Quais municípios não têm liderança cadastrada?". ' +
|
||||
'The pending criterion is always declared in the response; combine the ids/slugs with buildCampaignLinks.',
|
||||
inputSchema: z.object({
|
||||
escopo: z
|
||||
scope: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
|
|
@ -57,11 +67,11 @@ export const getPendingLeaderships = (ctx: AIToolContext) =>
|
|||
.describe(
|
||||
'Território de identidade, cidade ou município (ex.: "Vale do Jiquiriça", "Salvador", "Feira de Santana"). Omita para usar o escopo de acesso do usuário.',
|
||||
),
|
||||
filtro: z
|
||||
filter: z
|
||||
.enum(['sem_assessor'])
|
||||
.optional()
|
||||
.describe('Restringe a lista de lideranças às que não têm assessor responsável.'),
|
||||
modo: z
|
||||
mode: z
|
||||
.enum(['liderancas', 'municipios_sem_lideranca'])
|
||||
.optional()
|
||||
.default('liderancas')
|
||||
|
|
@ -77,128 +87,88 @@ export const getPendingLeaderships = (ctx: AIToolContext) =>
|
|||
.default(20)
|
||||
.describe('Máximo de resultados (default 20, max 50).'),
|
||||
}),
|
||||
execute: async ({ escopo, filtro, modo, limit }) => {
|
||||
execute: async ({ scope, filter, mode, limit }) => {
|
||||
if (!isStaffCampaignRole(ctx.user.role)) return { error: DENIED_MESSAGE }
|
||||
|
||||
const scope = await resolveScope(ctx, escopo)
|
||||
if ('error' in scope) return scope
|
||||
const resolved = await resolveScope(ctx, scope)
|
||||
if ('error' in resolved) return resolved
|
||||
|
||||
if (modo === 'municipios_sem_lideranca') {
|
||||
if (filtro) {
|
||||
if (mode === 'municipios_sem_lideranca') {
|
||||
if (filter) {
|
||||
return { error: 'O filtro "sem assessor" só se aplica à lista de lideranças.' }
|
||||
}
|
||||
return listMunicipalitiesWithoutLeadership(ctx, scope, limit)
|
||||
return listMunicipalitiesWithoutLeadership(ctx, resolved, limit)
|
||||
}
|
||||
|
||||
return listPendingLeaderships(ctx, scope, filtro === 'sem_assessor', limit)
|
||||
return listPendingLeaderships(ctx, resolved, filter === 'sem_assessor', limit)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve the requested scope into its municipality set through the actor's
|
||||
* own access (an advisor asking outside the portfolio gets the empty scoped
|
||||
* set — never an existence lie). The generic error stays reserved for names
|
||||
* that match nothing at all.
|
||||
*/
|
||||
const resolveScope = async (
|
||||
ctx: AIToolContext,
|
||||
escopo: string | undefined,
|
||||
scope: string | undefined,
|
||||
): Promise<ResolvedScope | { error: string }> => {
|
||||
if (!escopo) {
|
||||
if (!scope) {
|
||||
return { kind: 'todos', name: null, municipalityIDs: null, municipalities: [] }
|
||||
}
|
||||
|
||||
const canonicalMunicipality = resolveMunicipalityName(escopo)
|
||||
if (canonicalMunicipality) {
|
||||
const result = await ctx.payload.find({
|
||||
collection: 'municipality',
|
||||
where: { name: { equals: canonicalMunicipality } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: { id: true, name: true, slug: true, city: true, region: true },
|
||||
overrideAccess: false,
|
||||
user: ctx.user,
|
||||
})
|
||||
const doc = result.docs[0]
|
||||
if (doc) {
|
||||
return {
|
||||
kind: 'municipio',
|
||||
name: doc.name,
|
||||
municipalityIDs: [doc.id],
|
||||
municipalities: [
|
||||
{
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
slug: doc.slug,
|
||||
city: doc.city ?? null,
|
||||
region: doc.region ?? null,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
// Salvador first: the virtual city is a first-class scope (19 ZE juntas) and
|
||||
// the catalog has no row literally named "Salvador" — the municipality query
|
||||
// would be a guaranteed-miss round trip.
|
||||
if (normalizeSearchPhrase(scope) === normalizeSearchPhrase(salvadorCity.city)) {
|
||||
const resolved = await resolveMunicipalities(ctx, { city: { equals: salvadorCity.city } })
|
||||
return { kind: 'cidade', name: salvadorCity.name, ...resolved }
|
||||
}
|
||||
|
||||
if (normalizeSearchPhrase(escopo) === normalizeSearchPhrase(salvadorCity.city)) {
|
||||
const result = await ctx.payload.find({
|
||||
collection: 'municipality',
|
||||
where: { city: { equals: salvadorCity.city } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { id: true, name: true, slug: true, city: true, region: true },
|
||||
overrideAccess: false,
|
||||
user: ctx.user,
|
||||
})
|
||||
const municipalities = result.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
slug: doc.slug,
|
||||
city: doc.city ?? null,
|
||||
region: doc.region ?? null,
|
||||
}))
|
||||
if (municipalities.length === 0) {
|
||||
return { error: `Nenhuma zona eleitoral encontrada para "${escopo}".` }
|
||||
}
|
||||
return {
|
||||
kind: 'cidade',
|
||||
name: salvadorCity.name,
|
||||
municipalityIDs: municipalities.map((m) => m.id),
|
||||
municipalities,
|
||||
}
|
||||
const canonicalMunicipality = resolveMunicipalityName(scope)
|
||||
if (canonicalMunicipality) {
|
||||
const resolved = await resolveMunicipalities(ctx, { name: { equals: canonicalMunicipality } })
|
||||
return { kind: 'municipio', name: canonicalMunicipality, ...resolved }
|
||||
}
|
||||
|
||||
const territory = bahiaIdentityTerritories.find(
|
||||
(name) => normalizeSearchPhrase(name) === normalizeSearchPhrase(escopo),
|
||||
(name) => normalizeSearchPhrase(name) === normalizeSearchPhrase(scope),
|
||||
)
|
||||
if (territory) {
|
||||
const result = await ctx.payload.find({
|
||||
collection: 'municipality',
|
||||
where: { region: { equals: territory } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { id: true, name: true, slug: true, city: true, region: true },
|
||||
overrideAccess: false,
|
||||
user: ctx.user,
|
||||
})
|
||||
const municipalities = result.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
slug: doc.slug,
|
||||
city: doc.city ?? null,
|
||||
region: doc.region ?? null,
|
||||
}))
|
||||
if (municipalities.length === 0) {
|
||||
return { error: `Nenhum município encontrado no território "${territory}".` }
|
||||
}
|
||||
return {
|
||||
kind: 'regiao',
|
||||
name: territory,
|
||||
municipalityIDs: municipalities.map((m) => m.id),
|
||||
municipalities,
|
||||
}
|
||||
const resolved = await resolveMunicipalities(ctx, { region: { equals: territory } })
|
||||
return { kind: 'regiao', name: territory, ...resolved }
|
||||
}
|
||||
|
||||
return {
|
||||
error: `Escopo não reconhecido: "${escopo}". Use um município, "Salvador" ou um território de identidade da Bahia.`,
|
||||
error: `Escopo não reconhecido: "${scope}". Use um município, "Salvador" ou um território de identidade da Bahia.`,
|
||||
}
|
||||
}
|
||||
|
||||
const resolveMunicipalities = async (
|
||||
ctx: AIToolContext,
|
||||
where: Where,
|
||||
): Promise<{ municipalityIDs: number[]; municipalities: ScopeMunicipality[] }> => {
|
||||
const result = await ctx.payload.find({
|
||||
collection: 'municipality',
|
||||
where,
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { id: true, name: true, slug: true, city: true, region: true },
|
||||
overrideAccess: false,
|
||||
user: ctx.user,
|
||||
})
|
||||
const municipalities = result.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
slug: doc.slug,
|
||||
city: doc.city ?? null,
|
||||
region: doc.region ?? null,
|
||||
}))
|
||||
return { municipalityIDs: municipalities.map((m) => m.id), municipalities }
|
||||
}
|
||||
|
||||
const listPendingLeaderships = async (
|
||||
ctx: AIToolContext,
|
||||
scope: ResolvedScope,
|
||||
|
|
@ -231,23 +201,18 @@ const listPendingLeaderships = async (
|
|||
user: ctx.user,
|
||||
})
|
||||
|
||||
const docs = leaderships.docs as unknown as Array<{
|
||||
id: number
|
||||
contact: number | string | null
|
||||
municipalities: Array<number | string> | null
|
||||
supportStatus: string
|
||||
advisors: Array<number | string> | null
|
||||
updatedAt: string | null
|
||||
}>
|
||||
const docs = leaderships.docs as unknown as PendingLeadershipDoc[]
|
||||
|
||||
const leadershipIDs = docs.map((doc) => doc.id)
|
||||
// Only engaged leaderships consult the pledge axis — a_abordar/em_disputa
|
||||
// are pending regardless, so their pledges are never fetched.
|
||||
const engagedIDs = docs.filter((doc) => doc.supportStatus === 'engajado').map((doc) => doc.id)
|
||||
|
||||
const pledgeAndClauses: Where[] = [{ leadership: { in: leadershipIDs } }]
|
||||
if (scope.municipalityIDs) {
|
||||
pledgeAndClauses.push({ municipality: { in: scope.municipalityIDs } })
|
||||
}
|
||||
const pledgedLeadershipIDs = new Set<number>()
|
||||
if (leadershipIDs.length > 0) {
|
||||
if (engagedIDs.length > 0) {
|
||||
const pledgeAndClauses: Where[] = [{ leadership: { in: engagedIDs } }]
|
||||
if (scope.municipalityIDs) {
|
||||
pledgeAndClauses.push({ municipality: { in: scope.municipalityIDs } })
|
||||
}
|
||||
const pledges = await payload.find({
|
||||
collection: 'votePledge',
|
||||
where: { and: pledgeAndClauses },
|
||||
|
|
@ -273,9 +238,9 @@ const listPendingLeaderships = async (
|
|||
const total = pending.length
|
||||
const top = pending.slice(0, limit)
|
||||
|
||||
const contactIDs = uniqueIDs(top.flatMap((doc) => [doc.contact]))
|
||||
const municipalityIDs = uniqueIDs(top.flatMap((doc) => doc.municipalities ?? []))
|
||||
const advisorIDs = uniqueIDs(top.flatMap((doc) => doc.advisors ?? []))
|
||||
const contactIDs = uniqueRelationshipIds(top.flatMap((doc) => [doc.contact]))
|
||||
const municipalityIDs = uniqueRelationshipIds(top.flatMap((doc) => doc.municipalities ?? []))
|
||||
const advisorIDs = uniqueRelationshipIds(top.flatMap((doc) => doc.advisors ?? []))
|
||||
|
||||
const [contactNames, municipalityNames, advisorNames] = await Promise.all([
|
||||
loadNamesByIds(ctx, 'contact', contactIDs),
|
||||
|
|
@ -285,35 +250,42 @@ const listPendingLeaderships = async (
|
|||
|
||||
const liderancas = top.map((doc) => {
|
||||
const contactID = relationshipId(doc.contact)
|
||||
const municipalities = (doc.municipalities ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null)
|
||||
const advisors = (doc.advisors ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null)
|
||||
return {
|
||||
id: doc.id,
|
||||
nome: contactNames.get(contactID ?? -1) ?? 'Sem nome',
|
||||
municipios: municipalities.map((id) => ({
|
||||
// Unresolved names mean out-of-access links (advisor portfolio): the chip
|
||||
// is dropped instead of leaking a bare id — same policy as the UI.
|
||||
const municipios = uniqueRelationshipIds(doc.municipalities)
|
||||
.map((id) => ({
|
||||
id,
|
||||
nome: municipalityNames.get(id)?.name ?? null,
|
||||
slug: municipalityNames.get(id)?.slug ?? null,
|
||||
})),
|
||||
}))
|
||||
.filter((m) => m.nome !== null)
|
||||
const assessores = uniqueRelationshipIds(doc.advisors).map((id) => ({
|
||||
id,
|
||||
nome: advisorNames.get(id) ?? null,
|
||||
}))
|
||||
return {
|
||||
id: doc.id,
|
||||
nome: contactNames.get(contactID ?? -1) ?? 'Sem nome',
|
||||
municipios,
|
||||
status:
|
||||
supportStatusLabels[doc.supportStatus as keyof typeof supportStatusLabels] ??
|
||||
doc.supportStatus,
|
||||
assessores: advisors.map((id) => ({ id, nome: advisorNames.get(id) ?? null })),
|
||||
assessores,
|
||||
ultimaAtualizacao: doc.updatedAt ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
const truncado = total > top.length
|
||||
|
||||
return {
|
||||
escopo: { tipo: scope.kind, nome: scope.name },
|
||||
escopoRestrito: ctx.user.role === 'advisor',
|
||||
criterio: PENDING_CRITERION,
|
||||
filtroAplicado: { semAssessor: semAssessor },
|
||||
total,
|
||||
liderancas,
|
||||
truncado: total > top.length,
|
||||
truncado,
|
||||
...(truncado ? { dica: NARROW_SCOPE_HINT } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -360,19 +332,19 @@ const listMunicipalitiesWithoutLeadership = async (
|
|||
|
||||
const coveredIDs = new Set<number>()
|
||||
for (const doc of covered.docs) {
|
||||
for (const id of (Array.isArray(doc.municipalities) ? doc.municipalities : []).map(
|
||||
relationshipId,
|
||||
)) {
|
||||
if (id !== null) coveredIDs.add(id)
|
||||
for (const id of uniqueRelationshipIds(doc.municipalities)) {
|
||||
coveredIDs.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
const missing = scopeDocs.filter((doc) => !coveredIDs.has(doc.id))
|
||||
const total = missing.length
|
||||
const shown = missing.slice(0, limit)
|
||||
const truncado = total > shown.length
|
||||
|
||||
return {
|
||||
escopo: { tipo: scope.kind, nome: scope.name },
|
||||
escopoRestrito: ctx.user.role === 'advisor',
|
||||
criterio: EMPTY_MUNICIPALITIES_CRITERION,
|
||||
total,
|
||||
municipios: shown.map((doc) => ({
|
||||
|
|
@ -382,19 +354,11 @@ const listMunicipalitiesWithoutLeadership = async (
|
|||
regiao: doc.region,
|
||||
cidade: doc.city,
|
||||
})),
|
||||
truncado: total > shown.length,
|
||||
truncado,
|
||||
...(truncado ? { dica: NARROW_SCOPE_HINT } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueIDs = (values: Array<number | string | null>): number[] => {
|
||||
const ids: number[] = []
|
||||
for (const value of values) {
|
||||
const id = relationshipId(value)
|
||||
if (id !== null && !ids.includes(id)) ids.push(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
const loadNamesByIds = async (
|
||||
ctx: AIToolContext,
|
||||
collection: 'contact' | 'campaignUser',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const ctxFor = (user: CampaignUser, payload: Payload): AIToolContext => ({ user,
|
|||
|
||||
const leader = stub<CampaignUser>({ collection: 'campaignUser', role: 'leader' })
|
||||
const coordinator = stub<CampaignUser>({ collection: 'campaignUser', role: 'coordinator' })
|
||||
const advisor = stub<CampaignUser>({ collection: 'campaignUser', role: 'advisor' })
|
||||
|
||||
const scriptedPayload = (find: FindMock): Payload =>
|
||||
stub<Payload>({ find: find as Payload['find'] })
|
||||
|
|
@ -48,6 +49,14 @@ const execute =
|
|||
|
||||
const PENDING_STATUS_WHERE = { supportStatus: { in: ['a_abordar', 'em_disputa', 'engajado'] } }
|
||||
|
||||
const amargosa = {
|
||||
id: 10,
|
||||
name: 'Amargosa',
|
||||
slug: 'amargosa',
|
||||
city: 'Amargosa',
|
||||
region: 'Vale do Jiquiriçá',
|
||||
}
|
||||
|
||||
describe('getPendingLeaderships gate (B185)', () => {
|
||||
it('denies a leader with the chat-shaped error before any payload query', async () => {
|
||||
const payload = untouchablePayload
|
||||
|
|
@ -61,6 +70,7 @@ describe('getPendingLeaderships gate (B185)', () => {
|
|||
expect(result.total).toBe(0)
|
||||
expect(result.liderancas).toEqual([])
|
||||
expect(result.truncado).toBe(false)
|
||||
expect(result.escopoRestrito).toBe(false)
|
||||
expect(result.criterio).toContain('Engajado')
|
||||
expect(find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ collection: 'leadership', overrideAccess: false }),
|
||||
|
|
@ -69,9 +79,9 @@ describe('getPendingLeaderships gate (B185)', () => {
|
|||
|
||||
it('lets an advisor pass (RBAC atual via access control, não pelo gate)', async () => {
|
||||
const find = vi.fn().mockResolvedValue(findResult([]))
|
||||
const advisor = stub<CampaignUser>({ collection: 'campaignUser', role: 'advisor' })
|
||||
const result = (await execute(scriptedPayload(find), advisor)({})) as Record<string, unknown>
|
||||
expect(result.total).toBe(0)
|
||||
expect(result.escopoRestrito).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -108,29 +118,26 @@ describe('getPendingLeaderships pending criterion (B185)', () => {
|
|||
})
|
||||
expect(first.ultimaAtualizacao).toBe('2026-08-01T00:00:00.000Z')
|
||||
|
||||
const leadershipCall = find.mock.calls[0]![0] as { where: unknown }
|
||||
const leadershipCall = find.mock.calls[0]![0] as { where: unknown; sort: unknown }
|
||||
expect(leadershipCall.where).toEqual({ and: [PENDING_STATUS_WHERE] })
|
||||
expect(leadershipCall.sort).toBe('-updatedAt')
|
||||
|
||||
// Only engaged docs consult the pledge axis; the pledge where carries the
|
||||
// same access options as every other read.
|
||||
const pledgeCall = find.mock.calls[1]![0] as { where: unknown; overrideAccess: boolean }
|
||||
expect(pledgeCall.where).toEqual({ and: [{ leadership: { in: [2, 4] } }] })
|
||||
expect(pledgeCall.overrideAccess).toBe(false)
|
||||
})
|
||||
|
||||
it('um pledge fora do escopo não satisfaz o eixo do compromisso', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(
|
||||
findResult([
|
||||
{
|
||||
id: 10,
|
||||
name: 'Amargosa',
|
||||
slug: 'amargosa',
|
||||
city: 'Amargosa',
|
||||
region: 'Vale do Jiquiriçá',
|
||||
},
|
||||
]),
|
||||
)
|
||||
find.mockResolvedValueOnce(findResult([amargosa]))
|
||||
find.mockResolvedValueOnce(findResult([leadershipDoc({ id: 7, supportStatus: 'engajado' })]))
|
||||
find.mockResolvedValueOnce(findResult([{ leadership: 7 }]))
|
||||
find.mockResolvedValue(findResult([]))
|
||||
|
||||
const result = (await execute(scriptedPayload(find))({
|
||||
escopo: 'Vale do Jiquiriça',
|
||||
scope: 'Vale do Jiquiriça',
|
||||
})) as { liderancas: Array<{ id: number }> }
|
||||
|
||||
expect(result.liderancas).toEqual([])
|
||||
|
|
@ -140,27 +147,40 @@ describe('getPendingLeaderships pending criterion (B185)', () => {
|
|||
and: [{ leadership: { in: [7] } }, { municipality: { in: [10] } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('não consulta pledges quando não há lideranças engajadas no escopo', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(findResult([leadershipDoc({ id: 1, supportStatus: 'a_abordar' })]))
|
||||
find.mockResolvedValueOnce(findResult([{ id: 101, name: 'Lider A' }]))
|
||||
find.mockResolvedValue(findResult([{ id: 1, name: 'Amargosa', slug: 'amargosa' }]))
|
||||
|
||||
const result = (await execute(scriptedPayload(find))({})) as {
|
||||
liderancas: Array<{ id: number }>
|
||||
}
|
||||
|
||||
expect(result.liderancas).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
municipios: [{ id: 1, nome: 'Amargosa', slug: 'amargosa' }],
|
||||
assessores: [],
|
||||
nome: 'Lider A',
|
||||
status: 'A abordar',
|
||||
ultimaAtualizacao: '2026-08-01T00:00:00.000Z',
|
||||
},
|
||||
])
|
||||
expect(find).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPendingLeaderships scope resolution (B185)', () => {
|
||||
it('resolve território com tolerância a acento ("Vale do Jiquiriça" → região canônica)', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(
|
||||
findResult([
|
||||
{
|
||||
id: 10,
|
||||
name: 'Amargosa',
|
||||
slug: 'amargosa',
|
||||
city: 'Amargosa',
|
||||
region: 'Vale do Jiquiriçá',
|
||||
},
|
||||
]),
|
||||
)
|
||||
find.mockResolvedValueOnce(findResult([amargosa]))
|
||||
find.mockResolvedValueOnce(findResult([leadershipDoc({ id: 7, municipalities: [10] })]))
|
||||
find.mockResolvedValue(findResult([]))
|
||||
|
||||
const result = (await execute(scriptedPayload(find))({
|
||||
escopo: 'Vale do Jiquiriça',
|
||||
scope: 'Vale do Jiquiriça',
|
||||
})) as { escopo: { tipo: string; nome: string }; liderancas: unknown[] }
|
||||
|
||||
expect(result.escopo).toEqual({ tipo: 'regiao', nome: 'Vale do Jiquiriçá' })
|
||||
|
|
@ -173,9 +193,8 @@ describe('getPendingLeaderships scope resolution (B185)', () => {
|
|||
expect(result.liderancas).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resolve "Salvador" como cidade agregando as 19 zonas', async () => {
|
||||
it('resolve "Salvador" como cidade agregando as 19 zonas, sem consulta de município', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(findResult([]))
|
||||
find.mockResolvedValueOnce(
|
||||
findResult([
|
||||
{
|
||||
|
|
@ -198,13 +217,13 @@ describe('getPendingLeaderships scope resolution (B185)', () => {
|
|||
find.mockResolvedValue(findResult([]))
|
||||
|
||||
const result = (await execute(scriptedPayload(find))({
|
||||
escopo: 'Salvador',
|
||||
scope: 'Salvador',
|
||||
})) as { escopo: { tipo: string; nome: string } }
|
||||
|
||||
expect(result.escopo).toEqual({ tipo: 'cidade', nome: 'Salvador (cidade)' })
|
||||
const cityCall = find.mock.calls[1]![0] as { where: unknown }
|
||||
const cityCall = find.mock.calls[0]![0] as { where: unknown }
|
||||
expect(cityCall.where).toEqual({ city: { equals: 'Salvador' } })
|
||||
const leadershipCall = find.mock.calls[2]![0] as { where: unknown }
|
||||
const leadershipCall = find.mock.calls[1]![0] as { where: unknown }
|
||||
expect(leadershipCall.where).toEqual({
|
||||
and: [PENDING_STATUS_WHERE, { municipalities: { in: [11, 12] } }],
|
||||
})
|
||||
|
|
@ -227,7 +246,7 @@ describe('getPendingLeaderships scope resolution (B185)', () => {
|
|||
find.mockResolvedValue(findResult([]))
|
||||
|
||||
const result = (await execute(scriptedPayload(find))({
|
||||
escopo: 'Feira de Santana',
|
||||
scope: 'Feira de Santana',
|
||||
})) as { escopo: { tipo: string; nome: string } }
|
||||
|
||||
expect(result.escopo).toEqual({ tipo: 'municipio', nome: 'Feira de Santana' })
|
||||
|
|
@ -236,10 +255,39 @@ describe('getPendingLeaderships scope resolution (B185)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('município real fora do portfólio do assessor devolve lista vazia escopada, sem erro', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(findResult([]))
|
||||
find.mockResolvedValueOnce(findResult([]))
|
||||
find.mockResolvedValue(findResult([]))
|
||||
|
||||
const result = (await execute(
|
||||
scriptedPayload(find),
|
||||
advisor,
|
||||
)({
|
||||
scope: 'Vale do Jiquiriça',
|
||||
})) as {
|
||||
escopo: { tipo: string; nome: string }
|
||||
escopoRestrito: boolean
|
||||
total: number
|
||||
liderancas: unknown[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.escopo).toEqual({ tipo: 'regiao', nome: 'Vale do Jiquiriçá' })
|
||||
expect(result.escopoRestrito).toBe(true)
|
||||
expect(result.total).toBe(0)
|
||||
expect(result.liderancas).toEqual([])
|
||||
expect((find.mock.calls[1]![0] as { where: unknown }).where).toEqual({
|
||||
and: [PENDING_STATUS_WHERE, { municipalities: { in: [] } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('escopo desconhecido devolve erro sem consultar lideranças', async () => {
|
||||
const find = vi.fn()
|
||||
const result = (await execute(scriptedPayload(find))({
|
||||
escopo: 'Lugar Inexistente',
|
||||
scope: 'Lugar Inexistente',
|
||||
})) as { error: string }
|
||||
expect(result.error).toContain('Escopo não reconhecido')
|
||||
expect(find).not.toHaveBeenCalled()
|
||||
|
|
@ -253,7 +301,7 @@ describe('getPendingLeaderships filtros e modos (B185)', () => {
|
|||
find.mockResolvedValue(findResult([]))
|
||||
|
||||
const result = (await execute(scriptedPayload(find))({
|
||||
filtro: 'sem_assessor',
|
||||
filter: 'sem_assessor',
|
||||
})) as { filtroAplicado: { semAssessor: boolean }; liderancas: unknown[] }
|
||||
|
||||
expect(result.filtroAplicado).toEqual({ semAssessor: true })
|
||||
|
|
@ -289,8 +337,8 @@ describe('getPendingLeaderships filtros e modos (B185)', () => {
|
|||
find.mockResolvedValue(findResult([]))
|
||||
|
||||
const result = (await execute(scriptedPayload(find))({
|
||||
escopo: 'Vale do Jiquiriça',
|
||||
modo: 'municipios_sem_lideranca',
|
||||
scope: 'Vale do Jiquiriça',
|
||||
mode: 'municipios_sem_lideranca',
|
||||
})) as {
|
||||
total: number
|
||||
criterio: string
|
||||
|
|
@ -312,17 +360,43 @@ describe('getPendingLeaderships filtros e modos (B185)', () => {
|
|||
expect(coveredCall.where).toEqual({ municipalities: { in: [1, 2, 3] } })
|
||||
})
|
||||
|
||||
it('modo municipios_sem_lideranca sem escopo consulta todos os municípios do ator', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(
|
||||
findResult([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Amargosa',
|
||||
slug: 'amargosa',
|
||||
city: 'Amargosa',
|
||||
region: 'Vale do Jiquiriçá',
|
||||
},
|
||||
]),
|
||||
)
|
||||
find.mockResolvedValueOnce(findResult([{ municipalities: [1] }]))
|
||||
find.mockResolvedValue(findResult([]))
|
||||
|
||||
const result = (await execute(scriptedPayload(find))({
|
||||
mode: 'municipios_sem_lideranca',
|
||||
})) as { total: number }
|
||||
|
||||
expect(result.total).toBe(0)
|
||||
const allCall = find.mock.calls[0]![0] as { collection: string; where: unknown }
|
||||
expect(allCall.collection).toBe('municipality')
|
||||
expect(allCall.where).toEqual({})
|
||||
})
|
||||
|
||||
it('rejeita combinar o filtro sem_assessor com o modo de municípios', async () => {
|
||||
const find = vi.fn()
|
||||
const result = (await execute(scriptedPayload(find))({
|
||||
modo: 'municipios_sem_lideranca',
|
||||
filtro: 'sem_assessor',
|
||||
mode: 'municipios_sem_lideranca',
|
||||
filter: 'sem_assessor',
|
||||
})) as { error: string }
|
||||
expect(result.error).toContain('só se aplica à lista de lideranças')
|
||||
expect(find).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('trunca no limite mantendo o total real', async () => {
|
||||
it('trunca no limite mantendo o total real e devolve a dica de estreitar', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(
|
||||
findResult([
|
||||
|
|
@ -338,11 +412,13 @@ describe('getPendingLeaderships filtros e modos (B185)', () => {
|
|||
const result = (await execute(scriptedPayload(find))({ limit: 2 })) as {
|
||||
total: number
|
||||
truncado: boolean
|
||||
dica?: string
|
||||
liderancas: unknown[]
|
||||
}
|
||||
|
||||
expect(result.total).toBe(3)
|
||||
expect(result.truncado).toBe(true)
|
||||
expect(result.dica).toContain('Estreite o escopo')
|
||||
expect(result.liderancas).toHaveLength(2)
|
||||
})
|
||||
|
||||
|
|
@ -351,7 +427,6 @@ describe('getPendingLeaderships filtros e modos (B185)', () => {
|
|||
find.mockResolvedValueOnce(
|
||||
findResult([leadershipDoc({ id: 1, advisors: [501, 502], supportStatus: 'em_disputa' })]),
|
||||
)
|
||||
find.mockResolvedValueOnce(findResult([]))
|
||||
find.mockResolvedValueOnce(findResult([{ id: 101, name: 'Lider A' }]))
|
||||
find.mockResolvedValueOnce(findResult([{ id: 1, name: 'Amargosa', slug: 'amargosa' }]))
|
||||
find.mockResolvedValueOnce(
|
||||
|
|
@ -369,8 +444,26 @@ describe('getPendingLeaderships filtros e modos (B185)', () => {
|
|||
{ id: 501, nome: 'João Assessor' },
|
||||
{ id: 502, nome: 'Maria Assessora' },
|
||||
])
|
||||
const advisorCall = find.mock.calls[4]![0] as { collection: string; select: unknown }
|
||||
const advisorCall = find.mock.calls[3]![0] as { collection: string; select: unknown }
|
||||
expect(advisorCall.collection).toBe('campaignUser')
|
||||
expect(advisorCall.select).toEqual({ name: true })
|
||||
})
|
||||
|
||||
it('municípios fora do acesso são omitidos (nunca um id pelado no chip)', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(
|
||||
findResult([leadershipDoc({ id: 1, municipalities: [1, 99], supportStatus: 'a_abordar' })]),
|
||||
)
|
||||
find.mockResolvedValueOnce(findResult([{ id: 101, name: 'Lider A' }]))
|
||||
find.mockResolvedValueOnce(findResult([{ id: 1, name: 'Amargosa', slug: 'amargosa' }]))
|
||||
find.mockResolvedValue(findResult([]))
|
||||
|
||||
const result = (await execute(scriptedPayload(find))({})) as {
|
||||
liderancas: Array<{ municipios: unknown[] }>
|
||||
}
|
||||
|
||||
expect(result.liderancas[0]!.municipios).toEqual([
|
||||
{ id: 1, nome: 'Amargosa', slug: 'amargosa' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue