B141 — filtro por fonte na omnibox de apoiadores (#309)

Adiciona dimensão Fonte ao estado de lista, where-builder, aggregate SQL
e adapter omnibox (B128), com guard isSupporterSource e testes.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 23:03:22 +00:00
parent d0cd103b90
commit b509c7d7b3
No known key found for this signature in database
10 changed files with 190 additions and 7 deletions

View file

@ -0,0 +1,75 @@
# Impl: Apoiadores — filtro por fonte na omnibox
Status: aprovado
Atualizado em: 2026-08-02
Issue: #309
Intenção: docs/plans/apoiadores-omnibox-fonte.md
Appetite restante: herdado (~0,5 dia)
## Leitura da intenção
- **Outcome:** Staff filtra `/campanha/apoiadores` por **fonte de cadastro** (enum existente) via omnibox — chip, sugestões com rótulos pt-BR, KPIs refletem o recorte.
- **O que NÃO negociar:** exclusivo (um valor por vez, como intenção/cidade/município); sem filtro por lote/importador; leader lockdown inalterado; Consent/LGPD inalterado.
- **O que reavaliar:** hipótese cita `supporterPageData` — filtro é puramente URL + where-builder; page data não precisa mudar além de já consumir `SupporterListState`.
## Abordagem recomendada
```mermaid
flowchart LR
URL["?source=import_csv"] --> parse["parseSupporterListParams"]
parse --> state["SupporterListState.source"]
state --> omnibox["supporterOmnibox chips/suggestions"]
state --> where["toPayloadWhere + SQL aggregate"]
where --> list["lista + KPIs"]
```
**Opções consideradas:**
- A) Estender `SupporterListState` + omnibox adapter (padrão B128)
- B) Filtro só na UI sem URL (rejeitado — quebra compartilhamento/deep-link)
- C) Multi-select OR de fontes (rejeitado — produto assume exclusivo)
**Recomendação:** A — mesma máquina de intenção de voto: param `source`, type guard, chip, seeds estáticos de `supporterSourceLabels`, toggle exclusivo.
**Rejeitadas:** B (sem URL), C (OR multi-fonte).
### Componentes / mudanças
- **`isSupporterSource`** (`src/lib/schemas/supporter.ts`): guard do enum, espelho de `isSupporterVoteIntention`.
- **`supporterUi.ts`**: `source` em state, parse/serialize, param allowlist.
- **`supporterListFilters.ts` + `supporterListSqlFilters.ts`**: `source` no where Payload e SQL dos KPIs.
- **`supporterOmnibox.ts`**: grupo Fonte, chip, apply/remove/clear.
- **`SupporterFilters.tsx`**: placeholder menciona fonte.
- **Migration:** sem migration (campo já existe).
- **Access / Consent:** inalterado — filtro só restringe linhas já legíveis.
- **UI:** Impeccable B — encaixe na omnibox existente; sem novo componente.
### Dados → forma
- Filtro sobre lista já carregada; KPIs via aggregate existente — só adicionar condição `source`.
## Fases verificáveis
1. **Schema/server** — guard, parse, where, SQL + testes unit/int
2. **UI** — omnibox seeds/chips + placeholder
3. **Gates** — `pnpm gate:fast`; push via `pnpm push`
## Rabbit holes / Não escopo (engenharia)
- Filtro por lote CSV / `createdBy`
- Coluna visível de fonte na tabela (fora do item)
- Multi-select OR
## Riscos e mitigação
- **Leader lockdown:** filtro não altera access — where continua sob `canReadSupporter`.
- **KPIs desalinhados:** `toAggregateSqlConditions` ganha `source` no mesmo commit que `toPayloadWhere`.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes de domínio previstos (unit/int) onde access/write paths mudam
## Débitos deferidos (capture-review-debts)
- Nenhum previsto — mudança localizada no adapter B128.

View file

@ -91,7 +91,7 @@ export const SupporterFilters = ({
<CampaignListOmnibox
id="supporter-omnibox"
label="Filtrar apoiadores"
placeholder="Digite para filtrar (nome, intenção, cidade, município…)"
placeholder="Digite para filtrar (nome, intenção, fonte, cidade, município…)"
chips={chips}
suggestions={suggestions}
query={query}

View file

@ -42,6 +42,19 @@ export type SupporterVoteIntention = (typeof supporterVoteIntentions)[number]
export const isSupporterVoteIntention = (value: unknown): value is SupporterVoteIntention =>
typeof value === 'string' && (supporterVoteIntentions as readonly string[]).includes(value)
const supporterSources = [
'import_csv',
'manual',
'lideranca',
'convite',
'evento',
] as const
export type SupporterSource = (typeof supporterSources)[number]
export const isSupporterSource = (value: unknown): value is SupporterSource =>
typeof value === 'string' && (supporterSources as readonly string[]).includes(value)
export const resolveBahiaMunicipality = resolveMunicipalityName
const optionalBahiaCity = z

View file

@ -2,7 +2,7 @@ import type { Where } from 'payload'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import { normalizeBrazilianPhone } from '@/lib/phone'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import type { SupporterSource, SupporterVoteIntention } from '@/lib/schemas/supporter'
/**
* The filters the where-builder actually reads — a structural subset of
@ -12,6 +12,7 @@ import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
export type SupporterListFilterInput = {
q?: string
voteIntention?: SupporterVoteIntention
source?: SupporterSource
city?: string
municipality?: number
}
@ -60,6 +61,9 @@ export const toPayloadWhere = (state: SupporterListFilterInput): Where => {
if (state.voteIntention) {
filters.push({ voteIntention: { equals: state.voteIntention } })
}
if (state.source) {
filters.push({ source: { equals: state.source } })
}
if (state.city) {
filters.push({ 'contact.city': { equals: state.city } })
}

View file

@ -38,6 +38,10 @@ export const toAggregateSqlConditions = (
conditions.push(sql`"supporter"."vote_intention" = ${state.voteIntention}`)
}
if (state.source) {
conditions.push(sql`"supporter"."source" = ${state.source}`)
}
if (state.municipality) {
conditions.push(sql`"supporter"."municipality_id" = ${state.municipality}`)
}

View file

@ -10,6 +10,7 @@ import {
import {
buildSupporterListSearchParams,
parseSupporterListParams,
supporterSourceLabels,
supporterVoteIntentionLabels,
type SupporterListState,
} from '@/utilities/supporter/supporterUi'
@ -49,6 +50,13 @@ export const buildSupporterOmniboxChips = ({
})
}
if (state.source) {
chips.push({
id: `source:${state.source}`,
label: chipLabel('Fonte', supporterSourceLabels[state.source]),
})
}
if (state.city) {
chips.push({ id: `city:${state.city}`, label: chipLabel('Cidade', state.city) })
}
@ -89,6 +97,20 @@ export const buildSupporterOmniboxSuggestionSeeds = ({
)
}
for (const [value, label] of Object.entries(supporterSourceLabels)) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `source:${value}`,
group: 'Fonte',
label,
keywords: ['fonte', 'origem', 'cadastro', 'import', 'importacao', 'lideranca'],
},
{ emptyQueryVisible: true },
),
)
}
for (const option of cityOptions) {
seeds.push(
createOmniboxSuggestionSeed({
@ -121,7 +143,7 @@ export const filterSupporterOmniboxSuggestions = (
const setExclusiveField = (
state: SupporterListState,
field: 'voteIntention' | 'city' | 'municipality',
field: 'voteIntention' | 'source' | 'city' | 'municipality',
value: string | undefined,
): SupporterListState => {
const params = buildSupporterListSearchParams(withPageReset(state))
@ -129,6 +151,8 @@ const setExclusiveField = (
if (field === 'voteIntention') {
raw.voteIntention = value
} else if (field === 'source') {
raw.source = value
} else if (field === 'city') {
raw.city = value
} else {
@ -159,6 +183,15 @@ export const applySupporterOmniboxSuggestion = ({
return { kind: 'url', state: next }
}
if (suggestionId.startsWith('source:')) {
const value = suggestionId.slice(7)
const next =
state.source === value
? setExclusiveField(state, 'source', undefined)
: setExclusiveField(state, 'source', value)
return { kind: 'url', state: next }
}
if (suggestionId.startsWith('city:')) {
const value = suggestionId.slice(5)
const next =
@ -193,6 +226,10 @@ export const removeSupporterOmniboxChip = ({
return { kind: 'url', state: setExclusiveField(state, 'voteIntention', undefined) }
}
if (chipId.startsWith('source:')) {
return { kind: 'url', state: setExclusiveField(state, 'source', undefined) }
}
if (chipId.startsWith('city:')) {
return { kind: 'url', state: setExclusiveField(state, 'city', undefined) }
}

View file

@ -2,8 +2,10 @@ import type { Where } from 'payload'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import {
isSupporterSource,
isSupporterVoteIntention,
resolveBahiaMunicipality,
type SupporterSource,
type SupporterVoteIntention,
} from '@/lib/schemas/supporter'
import type { CampaignUser, Supporter } from '@/payload-types'
@ -38,13 +40,21 @@ export type SupporterListState = {
page: number
q?: string
voteIntention?: SupporterVoteIntention
source?: SupporterSource
city?: string
municipality?: number
}
type RawSearchParams = CampaignListRawSearchParams
const supporterListParamNames = ['q', 'voteIntention', 'city', 'municipality', 'page'] as const
const supporterListParamNames = [
'q',
'voteIntention',
'source',
'city',
'municipality',
'page',
] as const
const supporterListParamNameSet = new Set<string>(supporterListParamNames)
@ -54,12 +64,15 @@ export const parseSupporterListParams = (params: RawSearchParams): SupporterList
const city = resolveBahiaMunicipality(firstValue(params.city)) ?? undefined
const rawVoteIntention = firstValue(params.voteIntention)
const voteIntention = isSupporterVoteIntention(rawVoteIntention) ? rawVoteIntention : undefined
const rawSource = firstValue(params.source)
const source = isSupporterSource(rawSource) ? rawSource : undefined
const municipality = strictDecimalInteger(firstValue(params.municipality))
return {
page: rawPage ?? 1,
...(q ? { q } : {}),
...(voteIntention ? { voteIntention } : {}),
...(source ? { source } : {}),
...(city ? { city } : {}),
...(municipality ? { municipality } : {}),
}
@ -75,6 +88,7 @@ export const buildSupporterListSearchParams = (
page: String(page),
q: state.q,
voteIntention: state.voteIntention,
source: state.source,
city: state.city,
municipality: state.municipality === undefined ? undefined : String(state.municipality),
})
@ -82,6 +96,7 @@ export const buildSupporterListSearchParams = (
if (canonicalState.q) params.set('q', canonicalState.q)
if (canonicalState.voteIntention) params.set('voteIntention', canonicalState.voteIntention)
if (canonicalState.source) params.set('source', canonicalState.source)
if (canonicalState.city) params.set('city', canonicalState.city)
if (canonicalState.municipality) params.set('municipality', String(canonicalState.municipality))
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))

View file

@ -53,10 +53,11 @@ describe('supporterListFilters', () => {
})
})
it('applies vote intention, city, and municipality filters in both adapters', () => {
it('applies vote intention, source, city, and municipality filters in both adapters', () => {
const state = {
page: 1,
voteIntention: 'indeciso' as const,
source: 'import_csv' as const,
city: 'Salvador',
municipality: 42,
}
@ -64,6 +65,7 @@ describe('supporterListFilters', () => {
expect(toPayloadWhere(state)).toEqual({
and: [
{ voteIntention: { equals: 'indeciso' } },
{ source: { equals: 'import_csv' } },
{ 'contact.city': { equals: 'Salvador' } },
{ municipality: { equals: 42 } },
],
@ -71,6 +73,6 @@ describe('supporterListFilters', () => {
const aggregate = toAggregateSqlConditions(state)
expect(aggregate.needsContactJoin).toBe(true)
expect(aggregate.conditions).toHaveLength(3)
expect(aggregate.conditions).toHaveLength(4)
})
})

View file

@ -105,6 +105,30 @@ describe('list omnibox adapters (B128)', () => {
expect(chips[0]?.id).toBe('voteIntention:indeciso')
})
it('supporter applies source exclusively and builds chips', () => {
const base = parseSupporterListParams({})
const applied = applySupporterOmniboxSuggestion({
state: base,
suggestionId: 'source:import_csv',
})
expect(applied.kind).toBe('url')
if (applied.kind === 'url') expect(applied.state.source).toBe('import_csv')
const toggled = applySupporterOmniboxSuggestion({
state: parseSupporterListParams({ source: 'import_csv' }),
suggestionId: 'source:import_csv',
})
expect(toggled.kind).toBe('url')
if (toggled.kind === 'url') expect(toggled.state.source).toBeUndefined()
const chips = buildSupporterOmniboxChips({
state: parseSupporterListParams({ source: 'lideranca' }),
municipalityLabelsById: new Map(),
})
expect(chips[0]?.id).toBe('source:lideranca')
expect(chips[0]?.label).toContain('Liderança')
})
it('activity clears filters but keeps tab', () => {
const state = parseActivityListParams({ tab: 'todos', kind: 'comicio', status: 'planejado' })
const chips = buildActivityOmniboxChips({ state, municipalityLabelsById: new Map() })

View file

@ -32,6 +32,12 @@ describe('parseSupporterListParams', () => {
expect(parseSupporterListParams({ voteIntention: 'talvez' }).voteIntention).toBeUndefined()
})
it('keeps only known sources', () => {
expect(parseSupporterListParams({ source: 'import_csv' }).source).toBe('import_csv')
expect(parseSupporterListParams({ source: 'lideranca' }).source).toBe('lideranca')
expect(parseSupporterListParams({ source: 'spam' }).source).toBeUndefined()
})
it('canonicalizes city through the Bahia municipality resolver', () => {
expect(parseSupporterListParams({ city: ' salvador ' }).city).toBe('Salvador')
expect(parseSupporterListParams({ city: 'Gotham' }).city).toBeUndefined()
@ -53,10 +59,13 @@ describe('buildSupporterListSearchParams', () => {
page: 1,
q: 'ana',
voteIntention: 'certo',
source: 'import_csv',
city: 'Salvador',
municipality: 7,
})
expect(params.toString()).toBe('q=ana&voteIntention=certo&city=Salvador&municipality=7')
expect(params.toString()).toBe(
'q=ana&voteIntention=certo&source=import_csv&city=Salvador&municipality=7',
)
})
it('includes page only beyond 1 and supports the page override', () => {