mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
d0cd103b90
commit
b509c7d7b3
10 changed files with 190 additions and 7 deletions
75
docs/plans/apoiadores-omnibox-fonte-impl.md
Normal file
75
docs/plans/apoiadores-omnibox-fonte-impl.md
Normal 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.
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue