Merge branch 'cursor/1b47aa62' (B50 home search Assessores)
This commit is contained in:
commit
6c6a33d89f
19 changed files with 246 additions and 29 deletions
|
|
@ -12,7 +12,8 @@ alwaysApply: false
|
|||
- **B67 entregue em código (2026-07-29):** pan na strip de ações do Início (UX-1) — `CampaignHomeActionStrip` com drag-to-scroll em `pointer-fine` (`HOME_ACTION_STRIP_DRAG_THRESHOLD_PX` 8, `cursor-grab`/`grabbing`); scrollbar oculta (B58) mantida; touch `pan-x` nativo; `onClickCapture` suprime link após arraste. Unit em `campaignHomeActionButton.unit.spec.tsx`. Sem migration. Plano: [`restaurar-pan-strip-acoes-inicio.md`](../../../docs/plans/restaurar-pan-strip-acoes-inicio.md). Fecha Adiado drag desktop de B44.
|
||||
- **B58 entregue em código (2026-07-29):** polimento visual da strip de ações do Início (UX-1) — rótulos curtos no catálogo (`Ajustar votos`, `Registrar sinal`, `Ver esquecidos`, …); removido o `h2` "O que você quer fazer?" (`aria-label` da strip = "Ações rápidas"); scrollbar horizontal oculta (pan nativo touch; drag fine em **B67** ✓); escala ~1,05 no círculo (`pointer-fine` hover / `pointer-coarse` `data-pressing`) + `active:opacity-90`; e2e B47 retarget para `[data-slot="home-actions"]`. Sem migration/action/URL. Plano: [`polimento-strip-acoes-inicio.md`](../../../docs/plans/polimento-strip-acoes-inicio.md).
|
||||
- **Fill-in Cenário junto aos filtros entregue em código (2026-07-29):** em `/campanha/municipios` (staff), o seletor **Cenário** (`VoteEstimateScenarioField`) saiu do header do `MunicipalityListOverview` e entrou na barra slim de `MunicipalityFilters` (desktop: antes de Salvar/Limpar; mobile: após filtros staff, antes de Ordenar). `MunicipalityEstimateScenarioProvider` passou a envolver filtros + resultados; cenário continua estado local (fora da URL e do **Limpar**). Hint `VOTE_ESTIMATE_SCENARIO_FILTERS_HINT`. Sem migration/action/Consent. Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md).
|
||||
- **B48 entregue em código (2026-07-29):** primeiro provider da busca global do Início — grupo **Municípios** (municípios scoped + TIs no mesmo bloco) via `POST /campanha/home-search`, `searchHomeMunicipalities`, `HomeSearchMunicipalityGroup` no slot B47; readout 2022 alinhado à lista (`MunicipalityVotePositionReadout`). Próximo: **B49** lideranças (mesmo payload); **B54** grid/cap. Plano: [`busca-global-resultados-municipios.md`](../../../docs/plans/busca-global-resultados-municipios.md).
|
||||
- **B48 entregue em código (2026-07-29):** primeiro provider da busca global do Início — grupo **Municípios** (municípios scoped + TIs no mesmo bloco) via `POST /campanha/home-search`, `searchHomeMunicipalities`, `HomeSearchMunicipalityGroup` no slot B47; readout 2022 alinhado à lista (`MunicipalityVotePositionReadout`). Próximo: **B49** lideranças; **B50** assessores ✓; **B54** grid/cap. Plano: [`busca-global-resultados-municipios.md`](../../../docs/plans/busca-global-resultados-municipios.md).
|
||||
- **B50 entregue em código (2026-07-29):** grupo **Assessores** na busca global do Início — `searchHomeAdvisors` (só CG/candidato; assessor recebe `[]`); match word-start no nome; secundário = N municípios; `HomeSearchAdvisorGroup` após Municípios; `homeSearchHasAnyHits` no empty; `municipalityIdsByAdvisorIds` exportado de `advisorData`. Int `homeSearchAdvisors.int.spec.ts`. WA = **B55**. Plano: [`busca-global-resultados-assessores.md`](../../../docs/plans/busca-global-resultados-assessores.md).
|
||||
- **B47 entregue em código (2026-07-29):** busca global do Início (UX-1) — input staff-only com debounce 250 ms (`HOME_SEARCH_DEBOUNCE_MS`), sem botão Pesquisar; modo focado quando query debounced trim ≥2 chars esconde strip B45 e spacer thumb-zone (`CampaignHomeLayout` `focused`). Ilha `CampaignHomeStaffChrome` + `CampaignHomeSearch` + `HomeSearchContext` / `useHomeSearch` para grupos B48+; contrato em `src/lib/campaignHomeSearchContract.ts`. Leader continua sem omnibox (`CampaignHomeLayout` seco). Escape limpa. Sem migration. Plano: [`busca-global-inicio-input.md`](../../../docs/plans/busca-global-inicio-input.md). ~~Próximo: **B48** resultados Municípios.~~
|
||||
- **B46 entregue em código (2026-07-29):** thumb-zone do Início (UX-1 chassis) — `CampaignHomeLayout` ancora `CampaignHomeActions` na metade inferior do viewport em mobile (`min-h-full` no shell + spacer `flex-1` acima da strip; clearance existente `pb-24` + bottom nav fixa, sem `position: fixed` na strip) e no topo em `md+` (`md:min-h-0`). Slots `data-slot="home-actions"` e opcional `searchSlot` → `data-slot="home-search"` (ordem documento mobile ações→busca; desktop busca→ações via `order-1`/`order-2`/`order-3`) para **B47** ✓. Wire em `campanha/(app)/page.tsx`. Unit `campaignHomeLayout.unit.spec.ts`; e2e `campaignHomeActions` verde. Sem migration. Plano: [`posicao-botoes-acao-inicio-thumb-zone.md`](../../../docs/plans/posicao-botoes-acao-inicio-thumb-zone.md).
|
||||
- **B45 entregue em código (2026-07-29):** catálogo de ações do Início por persona (UX-1 chassis) — `src/lib/campaignHomeActions.ts` (`homeActionsForRole`, `toHomeActionButtonProps`) + ilha client `CampaignHomeActions` (ícones Lucide no client; RSC passa `role` + `uncoveredMunicipalitiesHref` de `buildMunicipalityListHref`). Staff (coordinator/candidate/advisor): 6 ações; `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; demais inertes (`cursor-default`). Leader: 2 ações; `my-contacts` → `/campanha/contatos`. Seção "O que você quer fazer?" acima da strip B44. Unit `campaignHomeActions.unit.spec.ts`; e2e `campaignHomeActions.e2e.spec.ts`. Sem migration. Plano: [`catalogo-acoes-inicio-por-persona.md`](../../../docs/plans/catalogo-acoes-inicio-por-persona.md).
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
# Busca global — resultados Assessores
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-29
|
||||
Status: entregue (2026-07-29)
|
||||
Atualizado em: 2026-07-29 — as-built: `searchHomeAdvisors` (`isUnrestrictedCampaignRole` → `[]` para assessor; word-start no nome; `municipalityIdsByAdvisorIds` exportado de `advisorData`); `HomeSearchAdvisorHit` + campo `advisors` no `POST /campanha/home-search`; `HomeSearchAdvisorGroup` + `HomeSearchHitRow` compartilhado; empty via `homeSearchHasAnyHits`; secundário = contagem de municípios (sem e-mail); sem migration.
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B50 — busca global)
|
||||
Impeccable: B — grupo de hits no slot B47
|
||||
Appetite: ~0,5 dia eng; loader + grupo; sem WA (B55)
|
||||
|
|
@ -40,7 +40,7 @@ Grupo “Assessores”. Clique → `/campanha/assessores/[id]`. WhatsApp = **B55
|
|||
|
||||
## Abordagem proposta
|
||||
|
||||
Loader espelhando `advisorData`; grupo UI; pluga no registry B47.
|
||||
Loader espelhando `advisorData`; grupo UI como **child** do slot B47 + mesmo `POST` que B48 (não registry plugável).
|
||||
|
||||
## Dependências
|
||||
|
||||
|
|
@ -54,9 +54,19 @@ WA → **B55**. Outros grupos / grid → B48–B54.
|
|||
|
||||
**Expor e-mail `@planilha.invalid`.** **Mitigação:** não mostrar e-mail inválido; preferir nome + N municípios.
|
||||
|
||||
## Já resolvido no simplify (2026-07-29 — não reabrir)
|
||||
|
||||
- Prop `leading` morta removida de `HomeSearchHitRow`.
|
||||
- Campo de desempate do sort renomeado `votes2022` → `tieBreakDesc` em `homeSearchMunicipalityMatch.ts` (assessores usam `municipalityCount` como tie-break).
|
||||
- `select: { name: true }` no scan de assessores (`role` já filtrado no `where`).
|
||||
- Rebase com **B68**: `mode: suggest` intacto; assessores só no ramo `mode: search` (`Promise.all` com municípios).
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
Nenhum neste item.
|
||||
- **Orquestrador único do POST** (derivar contagem de municípios do `loadMunicipalityScope` já pago no ramo municípios, em vez de `municipalityIdsByAdvisorIds` extra). **Gatilho:** latência perceptível no Início **ou** antes de **B51+** se o mesmo POST continuar pesado.
|
||||
- **Scan completo de `campaignUser` advisor** (`limit: 0`) + filtro word-start em memória. **Gatilho:** roster de assessores crescer além do tamanho confortável da planilha / considerar `pg_trgm` ou prefixo SQL.
|
||||
- **`filterAndRankHomeSearchByName` / `HomeSearchResultGroup`.** **Gatilho:** 3º loader ou 3º grupo (B51+).
|
||||
- **Carga duplicada município+pledge no POST** (herdada de B48; B50 só adiciona ramo paralelo). Ver [busca-global-resultados-municipios.md](busca-global-resultados-municipios.md) § Adiado.
|
||||
|
||||
## Referências
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ Componentes:
|
|||
- **`reloadStaffActor` no handler** (JWT role stale). **Gatilho:** próxima rota JSON staff-only que toque o mesmo padrão.
|
||||
- **Builder compartilhado de href `/campanha/municipios/[slug]`.** **Gatilho:** 3º call site além de lista + busca.
|
||||
- **Coalescer POSTs abortados no servidor.** **Gatilho:** debounce 250 ms gerar carga Neon mensurável em produção.
|
||||
- **B50 assessores no mesmo POST (`searchHomeAdvisors` em paralelo com municípios).** v1 aceita query extra de portfolio só quando há match de nome. **Gatilho:** ver [busca-global-resultados-assessores.md](busca-global-resultados-assessores.md) § Adiado (orquestrador / scan de roster).
|
||||
|
||||
## Referências
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
- ~~**B48** Busca — resultados Municípios (+ TIs no mesmo grupo; flag de prioridade alinhada; “2022” / agregado à direita; sem card)~~ — **entregue 2026-07-29** (`POST /campanha/home-search`, `searchHomeMunicipalities`, `HomeSearchMunicipalityGroup`; word-start; assessor scoped; TIs via `loadTerritoryOverview`) · [plano](plans/busca-global-resultados-municipios.md)
|
||||
- ~~**B68** Sugestões na busca aberta (empty state por papel)~~ — **entregue 2026-07-29** (`POST /campanha/home-search` com `mode: 'suggest'`; `loadHomeSearchSuggestions` + `rankHomeSearchSuggestMunicipalities`; assessor = carteira fria; CG/candidato = `priority === 'alta'` por déficit central + frescor; `homeSearchUiFocused` + seção **Sugestões**; `resultKind`; sem migration) · [plano](plans/sugestoes-busca-vazia-inicio.md)
|
||||
- **B49** Busca — resultados Lideranças · Depende de B47 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-liderancas.md)
|
||||
- **B50** Busca — resultados Assessores (access B19) · Depende de B47 · ~0,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-assessores.md)
|
||||
- **B50** ~~Busca — resultados Assessores (access B19)~~ — **entregue 2026-07-29** (`searchHomeAdvisors` + `HomeSearchAdvisorGroup`; unrestricted only; word-start no nome; contagem de municípios na linha; `POST /campanha/home-search` agrega `advisors`) · Depende de B47 ✓ · [plano](plans/busca-global-resultados-assessores.md)
|
||||
- **B51** Busca — resultados Atividades · Depende de B47 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-atividades.md)
|
||||
- **B52** Busca — resultados Dobradinhas · Depende de B47 · ~0,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-dobradinhas.md)
|
||||
- **B53** Busca — resultados Demandas · Depende de B47 · ~0,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-demandas.md)
|
||||
|
|
@ -225,7 +225,7 @@ flowchart TD
|
|||
B48n["B48 ✓ Busca<br/>Municípios + TIs"]
|
||||
B68n["B68 Sugestões busca<br/>vazia por papel"]
|
||||
B49n["B49 Busca Lideranças"]
|
||||
B50n["B50 Busca Assessores"]
|
||||
B50n["B50 ✓ Busca Assessores"]
|
||||
B51n["B51 Busca Atividades"]
|
||||
B52n["B52 Busca Dobradinhas"]
|
||||
B53n["B53 Busca Demandas"]
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { homeSearchBodySchema } from '@/lib/schemas/homeSearch'
|
|||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
|
||||
import { loadHomeSearchSuggestions } from '@/utilities/homeSearch/loadHomeSearchSuggestions'
|
||||
import { searchHomeAdvisors } from '@/utilities/homeSearch/searchHomeAdvisors'
|
||||
import { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
|
@ -27,7 +28,10 @@ export const POST = campaignJsonMutationRoute(
|
|||
return NextResponse.json<HomeSearchSuccessResponse>(result)
|
||||
}
|
||||
|
||||
const result = await searchHomeMunicipalities(payload, actor, body.query)
|
||||
return NextResponse.json<HomeSearchSuccessResponse>(result)
|
||||
const [municipalityResult, advisors] = await Promise.all([
|
||||
searchHomeMunicipalities(payload, actor, body.query),
|
||||
searchHomeAdvisors(payload, actor, body.query),
|
||||
])
|
||||
return NextResponse.json<HomeSearchSuccessResponse>({ ...municipalityResult, advisors })
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { CampaignHomeActions } from '@/components/campaign/dashboard/CampaignHom
|
|||
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
|
||||
import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/CampaignHomeStaffChrome'
|
||||
import { CampaignHomeSummary } from '@/components/campaign/dashboard/CampaignHomeSummary'
|
||||
import { HomeSearchAdvisorGroup } from '@/components/campaign/dashboard/HomeSearchAdvisorGroup'
|
||||
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
|
||||
import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
|
|
@ -46,6 +47,7 @@ export default async function CampaignHomePage() {
|
|||
searchResults={
|
||||
<HomeSearchResultsShell>
|
||||
<HomeSearchMunicipalityGroup />
|
||||
<HomeSearchAdvisorGroup />
|
||||
</HomeSearchResultsShell>
|
||||
}
|
||||
summarySlot={summarySlot}
|
||||
|
|
|
|||
37
src/components/campaign/dashboard/HomeSearchAdvisorGroup.tsx
Normal file
37
src/components/campaign/dashboard/HomeSearchAdvisorGroup.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
'use client'
|
||||
|
||||
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
|
||||
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
|
||||
const formatMunicipalityCount = (count: number): string => {
|
||||
if (count === 1) return '1 município'
|
||||
return `${count} municípios`
|
||||
}
|
||||
|
||||
export const HomeSearchAdvisorGroup = () => {
|
||||
const { results } = useHomeSearchResults()
|
||||
|
||||
if (results.status !== 'success') return null
|
||||
|
||||
const { advisors } = results.data
|
||||
if (advisors.length === 0) return null
|
||||
|
||||
return (
|
||||
<section aria-label="Assessores" className="flex flex-col gap-1">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Assessores
|
||||
</h2>
|
||||
<ul className="flex flex-col">
|
||||
{advisors.map((hit) => (
|
||||
<li key={hit.id}>
|
||||
<HomeSearchHitRow
|
||||
href={`/campanha/assessores/${hit.id}`}
|
||||
primary={hit.name}
|
||||
secondary={formatMunicipalityCount(hit.municipalityCount)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,20 +6,20 @@ import type { ReactNode } from 'react'
|
|||
import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PRIORITY_ICON_SLOT_CLASS = 'flex w-4 shrink-0 justify-center'
|
||||
const HOME_SEARCH_PRIORITY_ICON_SLOT_CLASS = 'flex w-4 shrink-0 justify-center'
|
||||
|
||||
export const HomeSearchHitRow = ({
|
||||
href,
|
||||
primary,
|
||||
secondary,
|
||||
trailing,
|
||||
showPriority,
|
||||
showPriority = false,
|
||||
}: {
|
||||
href: string
|
||||
primary: string
|
||||
secondary?: string
|
||||
trailing: ReactNode
|
||||
showPriority: boolean
|
||||
trailing?: ReactNode
|
||||
showPriority?: boolean
|
||||
}) => (
|
||||
<Link
|
||||
href={href}
|
||||
|
|
@ -28,7 +28,7 @@ export const HomeSearchHitRow = ({
|
|||
'rounded-md outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
>
|
||||
<span className={PRIORITY_ICON_SLOT_CLASS} aria-hidden={!showPriority}>
|
||||
<span className={HOME_SEARCH_PRIORITY_ICON_SLOT_CLASS} aria-hidden={!showPriority}>
|
||||
{showPriority ? <MunicipalityPriorityIndicator /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
|
|
@ -37,6 +37,6 @@ export const HomeSearchHitRow = ({
|
|||
<span className="block truncate text-xs text-muted-foreground">{secondary}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0">{trailing}</span>
|
||||
{trailing ? <span className="shrink-0">{trailing}</span> : null}
|
||||
</Link>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ export const HomeSearchMunicipalityGroup = () => {
|
|||
<HomeSearchHitRow
|
||||
href={buildTerritoryPageHref(hit.region)}
|
||||
primary={hit.region}
|
||||
showPriority={false}
|
||||
trailing={
|
||||
<span className="tabular-nums text-sm font-medium">
|
||||
{formatElectionNumber(hit.votes2022)}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { ReactNode } from 'react'
|
|||
|
||||
import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext'
|
||||
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import { homeSearchMunicipalityGroupHasHits } from '@/lib/campaignHomeSearchHits'
|
||||
import { homeSearchHasAnyHits } from '@/lib/campaignHomeSearchHits'
|
||||
|
||||
export const HomeSearchResultsShell = ({ children }: { children: ReactNode }) => {
|
||||
const { query } = useHomeSearch()
|
||||
|
|
@ -14,7 +14,7 @@ export const HomeSearchResultsShell = ({ children }: { children: ReactNode }) =>
|
|||
query.isActive &&
|
||||
resultKind === 'search' &&
|
||||
results.status === 'success' &&
|
||||
!homeSearchMunicipalityGroupHasHits(results.data)
|
||||
!homeSearchHasAnyHits(results.data)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ export type HomeSearchTerritoryHit = {
|
|||
votes2022: number
|
||||
}
|
||||
|
||||
export type HomeSearchAdvisorHit = {
|
||||
id: number
|
||||
name: string
|
||||
municipalityCount: number
|
||||
}
|
||||
|
||||
type HomeSearchResultKind = 'search' | 'suggest'
|
||||
|
||||
export type HomeSearchSuccessResponse = {
|
||||
|
|
@ -26,6 +32,7 @@ export type HomeSearchSuccessResponse = {
|
|||
resultKind: HomeSearchResultKind
|
||||
municipalities: HomeSearchMunicipalityHit[]
|
||||
territories: HomeSearchTerritoryHit[]
|
||||
advisors: HomeSearchAdvisorHit[]
|
||||
}
|
||||
|
||||
export const homeSearchMunicipalityGroupHasHits = (
|
||||
|
|
@ -45,3 +52,6 @@ export const toHomeSearchMunicipalityHit = (doc: {
|
|||
priority: doc.priority ?? null,
|
||||
votePosition2022: getMunicipalityVoteRank(doc.slug, DEFAULT_VOTE_RANK_YEAR),
|
||||
})
|
||||
|
||||
export const homeSearchHasAnyHits = (data: HomeSearchSuccessResponse): boolean =>
|
||||
homeSearchMunicipalityGroupHasHits(data) || data.advisors.length > 0
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ export const homeSearchNameRelevanceTier = (
|
|||
|
||||
export type HomeSearchNameSortable = {
|
||||
normalizedName: string
|
||||
votes2022: number
|
||||
/** Secondary sort key (higher sorts first after name relevance tier). */
|
||||
tieBreakDesc: number
|
||||
}
|
||||
|
||||
export const compareHomeSearchNameRelevance = (
|
||||
|
|
@ -25,7 +26,7 @@ export const compareHomeSearchNameRelevance = (
|
|||
homeSearchNameRelevanceTier(left.normalizedName, normalizedQuery) -
|
||||
homeSearchNameRelevanceTier(right.normalizedName, normalizedQuery)
|
||||
if (tierDelta !== 0) return tierDelta
|
||||
return right.votes2022 - left.votes2022
|
||||
return right.tieBreakDesc - left.tieBreakDesc
|
||||
}
|
||||
|
||||
export const normalizeHomeSearchName = (name: string): string => normalizeSearchPhrase(name)
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ const collectByAdvisor = <T>(
|
|||
* Intentional admin bypass: advisor rows already resolved after the unrestricted
|
||||
* route gate.
|
||||
*/
|
||||
const municipalityIdsByAdvisorIds = async (
|
||||
export const municipalityIdsByAdvisorIds = async (
|
||||
payload: Payload,
|
||||
advisorIDs: number[],
|
||||
): Promise<Map<number, number[]>> => {
|
||||
|
|
|
|||
|
|
@ -79,5 +79,6 @@ export const loadHomeSearchSuggestions = async (
|
|||
resultKind: 'suggest',
|
||||
municipalities: municipalityHits,
|
||||
territories: [],
|
||||
advisors: [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
src/utilities/homeSearch/searchHomeAdvisors.ts
Normal file
83
src/utilities/homeSearch/searchHomeAdvisors.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { homeSearchQueryIsActive, normalizeHomeSearchRaw } from '@/lib/campaignHomeSearchContract'
|
||||
import type { HomeSearchAdvisorHit } from '@/lib/campaignHomeSearchHits'
|
||||
import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
|
||||
import {
|
||||
compareHomeSearchNameRelevance,
|
||||
normalizeHomeSearchName,
|
||||
} from '@/lib/homeSearchMunicipalityMatch'
|
||||
import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { municipalityIdsByAdvisorIds } from '@/utilities/advisorData'
|
||||
|
||||
export const searchHomeAdvisors = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
rawQuery: string,
|
||||
): Promise<HomeSearchAdvisorHit[]> => {
|
||||
if (!isUnrestrictedCampaignRole(user.role)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const query = normalizeHomeSearchRaw(rawQuery)
|
||||
if (!homeSearchQueryIsActive(query)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizedQuery = normalizeHomeSearchName(query)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { role: { equals: 'advisor' } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'name',
|
||||
select: { name: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const matched = result.docs
|
||||
.map((doc) => {
|
||||
const normalizedName = normalizeHomeSearchName(doc.name)
|
||||
if (!matchesNormalizedAtWordStart(normalizedName, normalizedQuery)) {
|
||||
return null
|
||||
}
|
||||
return { id: doc.id, name: doc.name, normalizedName }
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null)
|
||||
|
||||
if (matched.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const advisorIDs = matched.map((row) => row.id)
|
||||
const municipalityIdsByAdvisor = await municipalityIdsByAdvisorIds(payload, advisorIDs)
|
||||
|
||||
return matched
|
||||
.map((row) => ({
|
||||
normalizedName: row.normalizedName,
|
||||
hit: {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
municipalityCount: municipalityIdsByAdvisor.get(row.id)?.length ?? 0,
|
||||
},
|
||||
}))
|
||||
.sort((left, right) =>
|
||||
compareHomeSearchNameRelevance(
|
||||
{
|
||||
normalizedName: left.normalizedName,
|
||||
tieBreakDesc: left.hit.municipalityCount,
|
||||
},
|
||||
{
|
||||
normalizedName: right.normalizedName,
|
||||
tieBreakDesc: right.hit.municipalityCount,
|
||||
},
|
||||
normalizedQuery,
|
||||
),
|
||||
)
|
||||
.map((row) => row.hit)
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ const emptyResponse = (): HomeSearchSuccessResponse => ({
|
|||
resultKind: 'search',
|
||||
municipalities: [],
|
||||
territories: [],
|
||||
advisors: [],
|
||||
})
|
||||
|
||||
export const searchHomeMunicipalities = async (
|
||||
|
|
@ -62,11 +63,11 @@ export const searchHomeMunicipalities = async (
|
|||
compareHomeSearchNameRelevance(
|
||||
{
|
||||
normalizedName: left.normalizedName,
|
||||
votes2022: left.hit.votePosition2022?.votes ?? 0,
|
||||
tieBreakDesc: left.hit.votePosition2022?.votes ?? 0,
|
||||
},
|
||||
{
|
||||
normalizedName: right.normalizedName,
|
||||
votes2022: right.hit.votePosition2022?.votes ?? 0,
|
||||
tieBreakDesc: right.hit.votePosition2022?.votes ?? 0,
|
||||
},
|
||||
normalizedQuery,
|
||||
),
|
||||
|
|
@ -94,11 +95,11 @@ export const searchHomeMunicipalities = async (
|
|||
compareHomeSearchNameRelevance(
|
||||
{
|
||||
normalizedName: left.normalizedName,
|
||||
votes2022: left.hit.votes2022,
|
||||
tieBreakDesc: left.hit.votes2022,
|
||||
},
|
||||
{
|
||||
normalizedName: right.normalizedName,
|
||||
votes2022: right.hit.votes2022,
|
||||
tieBreakDesc: right.hit.votes2022,
|
||||
},
|
||||
normalizedQuery,
|
||||
),
|
||||
|
|
@ -110,5 +111,6 @@ export const searchHomeMunicipalities = async (
|
|||
resultKind: 'search',
|
||||
municipalities: municipalityHits,
|
||||
territories: territoryHits,
|
||||
advisors: [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
tests/int/homeSearchAdvisors.int.spec.ts
Normal file
64
tests/int/homeSearchAdvisors.int.spec.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import config from '@/payload.config'
|
||||
import { searchHomeAdvisors } from '@/utilities/homeSearch/searchHomeAdvisors'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
describe('searchHomeAdvisors (B50)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('returns empty hits when query is shorter than the minimum', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
|
||||
const result = await searchHomeAdvisors(payload, coordinator, 'a')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('matches advisors by word-start on the name for unrestricted staff', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const advisor = await fixtures.createCampaignUser('advisor', { name: 'Zeca Oliveira Assessor' })
|
||||
|
||||
const result = await searchHomeAdvisors(payload, coordinator, 'Zeca')
|
||||
expect(result.some((hit) => hit.id === advisor.id)).toBe(true)
|
||||
expect(result.find((hit) => hit.id === advisor.id)?.name).toBe('Zeca Oliveira Assessor')
|
||||
})
|
||||
|
||||
it('returns no advisor hits for advisor role even when name matches', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor', { name: 'Zeca Oliveira Assessor' })
|
||||
|
||||
const result = await searchHomeAdvisors(payload, advisor, 'Zeca')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('reports municipality count on matched advisors', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const advisor = await fixtures.createCampaignUser('advisor', { name: 'Carteira Zeca' })
|
||||
const cairu = await fixtures.getMunicipality('cairu')
|
||||
const feira = await fixtures.getMunicipality('feira-de-santana')
|
||||
await fixtures.assignMunicipalityAdvisors(cairu.id, [advisor.id])
|
||||
await fixtures.assignMunicipalityAdvisors(feira.id, [advisor.id])
|
||||
|
||||
const result = await searchHomeAdvisors(payload, coordinator, 'Carteira')
|
||||
const hit = result.find((row) => row.id === advisor.id)
|
||||
expect(hit?.municipalityCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -11,12 +11,12 @@ describe('homeSearchMunicipalityMatch', () => {
|
|||
expect(homeSearchNameRelevanceTier('porto seguro', 'seg')).toBe(1)
|
||||
})
|
||||
|
||||
it('sorts by relevance tier then votes 2022 desc', () => {
|
||||
it('sorts by relevance tier then tie-break desc', () => {
|
||||
const normalizedQuery = 'cai'
|
||||
const hits = [
|
||||
{ normalizedName: 'porto seguro', votes2022: 9000 },
|
||||
{ normalizedName: 'cairu', votes2022: 100 },
|
||||
{ normalizedName: 'caicara', votes2022: 5000 },
|
||||
{ normalizedName: 'porto seguro', tieBreakDesc: 9000 },
|
||||
{ normalizedName: 'cairu', tieBreakDesc: 100 },
|
||||
{ normalizedName: 'caicara', tieBreakDesc: 5000 },
|
||||
]
|
||||
const sorted = [...hits].sort((a, b) => compareHomeSearchNameRelevance(a, b, normalizedQuery))
|
||||
expect(sorted.map((row) => row.normalizedName)).toEqual(['caicara', 'cairu', 'porto seguro'])
|
||||
|
|
|
|||
Loading…
Reference in a new issue