From b509c7d7b369cbfcfb1f6ca10d09e247c5911625 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:03:22 +0000 Subject: [PATCH] =?UTF-8?q?B141=20=E2=80=94=20filtro=20por=20fonte=20na=20?= =?UTF-8?q?omnibox=20de=20apoiadores=20(#309)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/plans/apoiadores-omnibox-fonte-impl.md | 75 +++++++++++++++++++ .../campaign/supporter/SupporterFilters.tsx | 2 +- src/lib/schemas/supporter.ts | 13 ++++ .../supporter/supporterListFilters.ts | 6 +- .../supporter/supporterListSqlFilters.ts | 4 + src/utilities/supporter/supporterOmnibox.ts | 39 +++++++++- src/utilities/supporter/supporterUi.ts | 17 ++++- tests/int/supporterListFilters.int.spec.ts | 6 +- tests/unit/listOmniboxB128.unit.spec.ts | 24 ++++++ tests/unit/supporterUi.unit.spec.ts | 11 ++- 10 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 docs/plans/apoiadores-omnibox-fonte-impl.md diff --git a/docs/plans/apoiadores-omnibox-fonte-impl.md b/docs/plans/apoiadores-omnibox-fonte-impl.md new file mode 100644 index 00000000..7005c70b --- /dev/null +++ b/docs/plans/apoiadores-omnibox-fonte-impl.md @@ -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. diff --git a/src/components/campaign/supporter/SupporterFilters.tsx b/src/components/campaign/supporter/SupporterFilters.tsx index a075483e..e02d7720 100644 --- a/src/components/campaign/supporter/SupporterFilters.tsx +++ b/src/components/campaign/supporter/SupporterFilters.tsx @@ -91,7 +91,7 @@ export const SupporterFilters = ({ 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 diff --git a/src/utilities/supporter/supporterListFilters.ts b/src/utilities/supporter/supporterListFilters.ts index 0b91b2b6..fbc15fa7 100644 --- a/src/utilities/supporter/supporterListFilters.ts +++ b/src/utilities/supporter/supporterListFilters.ts @@ -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 } }) } diff --git a/src/utilities/supporter/supporterListSqlFilters.ts b/src/utilities/supporter/supporterListSqlFilters.ts index 3ab0c6ca..4c9300d0 100644 --- a/src/utilities/supporter/supporterListSqlFilters.ts +++ b/src/utilities/supporter/supporterListSqlFilters.ts @@ -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}`) } diff --git a/src/utilities/supporter/supporterOmnibox.ts b/src/utilities/supporter/supporterOmnibox.ts index 9c9917ae..02de6753 100644 --- a/src/utilities/supporter/supporterOmnibox.ts +++ b/src/utilities/supporter/supporterOmnibox.ts @@ -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) } } diff --git a/src/utilities/supporter/supporterUi.ts b/src/utilities/supporter/supporterUi.ts index 8675c4d1..be51a8da 100644 --- a/src/utilities/supporter/supporterUi.ts +++ b/src/utilities/supporter/supporterUi.ts @@ -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(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)) diff --git a/tests/int/supporterListFilters.int.spec.ts b/tests/int/supporterListFilters.int.spec.ts index b29ad599..017cdd68 100644 --- a/tests/int/supporterListFilters.int.spec.ts +++ b/tests/int/supporterListFilters.int.spec.ts @@ -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) }) }) diff --git a/tests/unit/listOmniboxB128.unit.spec.ts b/tests/unit/listOmniboxB128.unit.spec.ts index 02ab4b39..88598be0 100644 --- a/tests/unit/listOmniboxB128.unit.spec.ts +++ b/tests/unit/listOmniboxB128.unit.spec.ts @@ -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() }) diff --git a/tests/unit/supporterUi.unit.spec.ts b/tests/unit/supporterUi.unit.spec.ts index e011031d..bebdcff2 100644 --- a/tests/unit/supporterUi.unit.spec.ts +++ b/tests/unit/supporterUi.unit.spec.ts @@ -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', () => {