Merge pull request #50 from fsolla/agent/B53-busca-global-demandas

feat(campanha): Demandas group in home global search (B53)
This commit is contained in:
Francisco Solla 2026-07-30 13:50:09 -03:00 committed by GitHub
commit a02ef48322
13 changed files with 278 additions and 8 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B53 entregue em código (2026-07-30):** sexto grupo da busca global do Início — **Demandas** via `searchHomeDemands` + `HomeSearchDemandGroup` (após Dobradinhas); `POST /campanha/home-search` agrega `demands`; match word-start no `title`; secundário `"{município} · {status}"`; staff access com `overrideAccess: false`; cap 25; int `homeSearchDemands.int.spec.ts`. WA = **B55**. Plano: [`busca-global-resultados-demandas.md`](../../../docs/plans/busca-global-resultados-demandas.md). Próximo na cadeia busca: **B54** grid/cap.
- **B70 entregue em código (2026-07-30):** wizard atualizar liderança (A4 standalone) — `/campanha/acoes/atualizar-lideranca?municipio=` com grid 2/3/4 (`WizardLeadershipStep`), form curto de 6 campos (`WizardLeadershipForm`), skip via `trailingAction` quando `entryAction !== 'update-leadership'`, Continuar após Salvar dirty → Início + toast; `loadWizardLeadershipTiles`; writes `updateLeadershipWizard`/`createLeadershipWizard` (Contact+Leadership em transação); `WIZARD_ENTRY_ACTION_QUERY_KEY` em `campaignActionRoutes.ts`; `truncateNameAtWordBoundary`. Sem migration. Plano: [`wizard-atualizar-lideranca.md`](../../../docs/plans/wizard-atualizar-lideranca.md).
- **B63 entregue em código (2026-07-30):** wizard registrar sinal (UX-1) — duas etapas no chassis B59: `WizardSignalTypeStep` (grid 2×3 de tiles transparentes + borda, ícone Lucide, Drawer bottom com `infoContent` provisório, `contentAlign="end"` no mobile) e `WizardSignalBodyStep` (textarea + Salvar explícito via `createMunicipalityListSignalFormAction` → `createMunicipalityUpdate` `kind: 'sinal'`). Contrato URL: `?municipio=` (B60) → `?signalType=` (tipo) → body; `?entry=` opcional — skip "Pular registro de sinal" visível só quando embutido (`entry` presente e ≠ `register-signal`), href skip → `/campanha` na v1. Helpers: `wizardSignalHref`, `resolveWizardSignalTypeParam`, `parseWizardEntryActionParam` em `campaignActionRoutes.ts`; `municipalitySignalTypeMeta.ts`; `wizardSignalUi.ts`. B63+ alinha `flowTitle`/`skip` ao chrome B75. Sem migration/Consent. Unit `campaignActionRoutes` + `wizardSignalUi`; e2e standalone + info não avança + skip embutido. Plano: [`wizard-registro-sinal.md`](../../../docs/plans/wizard-registro-sinal.md). Desbloqueia prefill de **B64** (soft).
- **B75 entregue em código (2026-07-30):** header mobile do wizard — em `/campanha/acoes/*` no mobile (`< md`), o Mandate Red deixa de ser sidebar+marca e vira chrome de fluxo: título do catálogo B45 (`wizardFlowTitleForSlug`), subtítulo do município após B60, 1ª etapa só X (`aria-label="Sair da ação"`), 2ª+ Voltar+X; `skip` substitui X quando encadeado (B63+). `CampaignWizardChromeProvider` + `CampaignMobileTopBar` no layout `(app)`; `CampaignWizardShell` publica chrome via `useLayoutEffect` (handoff entre passos sem flash app-mode); desktop mantém caption muted no `<main>` (`md:block`). Helpers: `campaignWizardActionIdForSlug`, `wizardFlowTitleForActionId`, `toCampaignWizardChromeState`. `data-wizard-route` em `acoes/layout.tsx`. Unit `campaignMobileTopBar`, `campaignWizardShell`, `campaignWizardCopy`; e2e `campaignWizardChrome.e2e.spec.ts` (viewport 390). Débito adiado: skip no desktop; split read/write context do provider (gatilho: re-render medido no inset). Plano: [`header-mobile-wizard-campanha.md`](../../../docs/plans/header-mobile-wizard-campanha.md). Desbloqueia polish mobile de B63/B64/B70/B77/B78 sobre chrome único.

View file

@ -87,7 +87,14 @@ jobs:
# chain; the second PR must wait or rebase.
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
# gh pr list against the default GITHUB_TOKEN.
contents: read
pull-requests: read
steps:
# gh expands --json files via git, so it needs the repo checked out.
- uses: actions/checkout@v4
- name: Count open PRs touching schema files
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,7 +1,7 @@
# Busca global — resultados Demandas
Status: rascunho
Atualizado em: 2026-07-29
Status: entregue (2026-07-30)
Atualizado em: 2026-07-30 — as-built: `searchHomeDemands` (`isStaffCampaignRole` gate; word-start no `title`; access `campaignDemand` com `overrideAccess: false`; cap 25; desempate `createdAt` desc); `HomeSearchDemandHit` + campo `demands` no `POST /campanha/home-search`; `HomeSearchDemandGroup` após Dobradinhas; secundário `"{município} · {status}"`; empty via `homeSearchHasAnyHits`; int `homeSearchDemands.int.spec.ts`; sem migration.
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B53 — busca global)
Impeccable: B — grupo de hits no slot B47
Appetite: ~0,5 dia eng; loader + grupo; share = B55

View file

@ -14,6 +14,7 @@ import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute
import { loadHomeSearchSuggestions } from '@/utilities/homeSearch/loadHomeSearchSuggestions'
import { searchHomeActivities } from '@/utilities/homeSearch/searchHomeActivities'
import { searchHomeAdvisors } from '@/utilities/homeSearch/searchHomeAdvisors'
import { searchHomeDemands } from '@/utilities/homeSearch/searchHomeDemands'
import { searchHomeLeaderships } from '@/utilities/homeSearch/searchHomeLeaderships'
import { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities'
import { searchHomeStateDeputies } from '@/utilities/homeSearch/searchHomeStateDeputies'
@ -43,13 +44,14 @@ export const POST = campaignJsonMutationRoute(
})
}
const [municipalityResult, advisors, leaderships, activities, stateDeputies] =
const [municipalityResult, advisors, leaderships, activities, stateDeputies, demands] =
await Promise.all([
searchHomeMunicipalities(payload, actor, body.query),
searchHomeAdvisors(payload, actor, body.query),
searchHomeLeaderships(payload, actor, body.query),
searchHomeActivities(payload, actor, body.query),
searchHomeStateDeputies(payload, actor, body.query),
searchHomeDemands(payload, actor, body.query),
])
return NextResponse.json<HomeSearchSuccessResponse>({
...municipalityResult,
@ -57,6 +59,7 @@ export const POST = campaignJsonMutationRoute(
leaderships,
activities,
stateDeputies,
demands,
})
},
)

View file

@ -8,6 +8,7 @@ import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/Campaig
import { CampaignHomeSummary } from '@/components/campaign/dashboard/CampaignHomeSummary'
import { HomeSearchActivityGroup } from '@/components/campaign/dashboard/HomeSearchActivityGroup'
import { HomeSearchAdvisorGroup } from '@/components/campaign/dashboard/HomeSearchAdvisorGroup'
import { HomeSearchDemandGroup } from '@/components/campaign/dashboard/HomeSearchDemandGroup'
import { HomeSearchLeadershipGroup } from '@/components/campaign/dashboard/HomeSearchLeadershipGroup'
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
@ -54,6 +55,7 @@ export default async function CampaignHomePage() {
<HomeSearchAdvisorGroup />
<HomeSearchActivityGroup />
<HomeSearchStateDeputyGroup />
<HomeSearchDemandGroup />
</HomeSearchResultsShell>
}
summarySlot={summarySlot}

View file

@ -0,0 +1,32 @@
'use client'
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
import { homeSearchDemandGroupHasHits } from '@/lib/campaignHomeSearchHits'
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
export const HomeSearchDemandGroup = () => {
const { results } = useHomeSearchResults()
if (results.status !== 'success') return null
if (!homeSearchDemandGroupHasHits(results.data)) return null
const { demands } = results.data
return (
<section aria-label="Demandas" className="flex flex-col gap-0.5">
<h2 className={HOME_SEARCH_GROUP_HEADING_CLASS}>Demandas</h2>
<ul className={HOME_SEARCH_GROUP_LIST_CLASS}>
{demands.map((hit) => (
<li key={hit.id}>
<HomeSearchHitRow
href={`/campanha/demandas/${hit.slug}`}
primary={hit.title}
secondary={hit.secondary}
/>
</li>
))}
</ul>
</section>
)
}

View file

@ -46,6 +46,13 @@ export type HomeSearchActivityHit = {
secondary: string
}
export type HomeSearchDemandHit = {
id: number
slug: string
title: string
secondary: string
}
type HomeSearchResultKind = 'search' | 'suggest'
export type HomeSearchSuccessResponse = {
@ -57,6 +64,7 @@ export type HomeSearchSuccessResponse = {
leaderships: HomeSearchLeadershipHit[]
stateDeputies: HomeSearchStateDeputyHit[]
activities: HomeSearchActivityHit[]
demands: HomeSearchDemandHit[]
}
export const homeSearchMunicipalityGroupHasHits = (
@ -75,6 +83,10 @@ export const homeSearchActivityGroupHasHits = (
data: Pick<HomeSearchSuccessResponse, 'activities'>,
): boolean => data.activities.length > 0
export const homeSearchDemandGroupHasHits = (
data: Pick<HomeSearchSuccessResponse, 'demands'>,
): boolean => data.demands.length > 0
export const formatHomeSearchMunicipalityCount = (count: number): string => {
if (count === 1) return '1 município'
return `${count} municípios`
@ -99,7 +111,8 @@ export const homeSearchHasAnyHits = (data: HomeSearchSuccessResponse): boolean =
data.advisors.length > 0 ||
homeSearchLeadershipGroupHasHits(data) ||
homeSearchStateDeputyGroupHasHits(data) ||
homeSearchActivityGroupHasHits(data)
homeSearchActivityGroupHasHits(data) ||
homeSearchDemandGroupHasHits(data)
export type WizardMunicipalitySearchSuccessResponse = {
status: 'success'

View file

@ -83,5 +83,6 @@ export const loadHomeSearchSuggestions = async (
leaderships: [],
stateDeputies: [],
activities: [],
demands: [],
}
}

View file

@ -0,0 +1,100 @@
import 'server-only'
import type { Payload } from 'payload'
import { homeSearchQueryIsActive, normalizeHomeSearchRaw } from '@/lib/campaignHomeSearchContract'
import type { HomeSearchDemandHit } from '@/lib/campaignHomeSearchHits'
import { HOME_SEARCH_STAFF_ONLY_MESSAGE } from '@/lib/campaignHomeSearchMessages'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import {
compareHomeSearchNameRelevance,
normalizeHomeSearchName,
} from '@/lib/homeSearchMunicipalityMatch'
import { isPopulatedRelationship } from '@/lib/relationship'
import { campaignDemandStatusLabels, type CampaignDemandStatus } from '@/lib/schemas/campaignDemand'
import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter'
import type { CampaignDemand, CampaignUser, Municipality } from '@/payload-types'
const HOME_SEARCH_DEMAND_HIT_CAP = 25
const formatDemandHomeSearchSecondary = (
municipalityName: string | null,
status: CampaignDemandStatus,
): string => {
const statusLabel = campaignDemandStatusLabels[status]
if (!municipalityName) return statusLabel
return `${municipalityName} · ${statusLabel}`
}
export const searchHomeDemands = async (
payload: Payload,
user: CampaignUser,
rawQuery: string,
): Promise<HomeSearchDemandHit[]> => {
if (!isStaffCampaignRole(user.role)) {
throw new Error(HOME_SEARCH_STAFF_ONLY_MESSAGE)
}
const query = normalizeHomeSearchRaw(rawQuery)
if (!homeSearchQueryIsActive(query)) {
return []
}
const normalizedQuery = normalizeHomeSearchName(query)
const result = await payload.find({
collection: 'campaignDemand',
where: { title: { contains: query } },
depth: 1,
limit: 0,
pagination: false,
select: {
id: true,
title: true,
slug: true,
status: true,
municipality: true,
createdAt: true,
},
user,
overrideAccess: false,
})
const candidates: Array<{
normalizedName: string
tieBreakDesc: number
hit: HomeSearchDemandHit
}> = []
for (const doc of result.docs as CampaignDemand[]) {
const normalizedName = normalizeHomeSearchName(doc.title)
if (!matchesNormalizedAtWordStart(normalizedName, normalizedQuery)) {
continue
}
const municipalityName = isPopulatedRelationship<Municipality>(doc.municipality)
? doc.municipality.name
: null
const tieBreakDesc = doc.createdAt ? Date.parse(doc.createdAt) : 0
candidates.push({
normalizedName,
tieBreakDesc: Number.isFinite(tieBreakDesc) ? tieBreakDesc : 0,
hit: {
id: doc.id,
slug: doc.slug,
title: doc.title,
secondary: formatDemandHomeSearchSecondary(
municipalityName,
doc.status as CampaignDemandStatus,
),
},
})
}
return candidates
.sort((left, right) => compareHomeSearchNameRelevance(left, right, normalizedQuery))
.slice(0, HOME_SEARCH_DEMAND_HIT_CAP)
.map((row) => row.hit)
}

View file

@ -29,6 +29,7 @@ const emptyResponse = (): HomeSearchSuccessResponse => ({
leaderships: [],
stateDeputies: [],
activities: [],
demands: [],
})
export const searchHomeMunicipalities = async (
@ -91,5 +92,6 @@ export const searchHomeMunicipalities = async (
leaderships: [],
stateDeputies: [],
activities: [],
demands: [],
}
}

View file

@ -0,0 +1,105 @@
// @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 { searchHomeDemands } from '@/utilities/homeSearch/searchHomeDemands'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('searchHomeDemands (B53)', () => {
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 searchHomeDemands(payload, coordinator, 'a')
expect(result).toEqual([])
})
it('matches demands by word-start on the title for staff', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality('cairu')
const title = fixtures.value('Material Zeca Centro')
const demand = await fixtures.createCampaignDemand({
municipality,
title,
createdBy: coordinator,
})
const result = await searchHomeDemands(payload, coordinator, 'Material')
const hit = result.find((row) => row.id === demand.id)
expect(hit?.title).toBe(title)
expect(hit?.slug).toBe(demand.slug)
expect(hit?.secondary).toContain('Cairu')
expect(hit?.secondary).toContain('Aberta')
})
it('does not match mid-word without word-start boundary', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality('cairu')
const title = fixtures.value('Transporte Comunitário')
const demand = await fixtures.createCampaignDemand({
municipality,
title,
createdBy: coordinator,
})
const result = await searchHomeDemands(payload, coordinator, 'ransporte')
expect(result.some((row) => row.id === demand.id)).toBe(false)
})
it('scopes demand hits to the advisor portfolio by municipality', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const coordinator = await fixtures.createCampaignUser('coordinator')
const administered = await fixtures.getMunicipality('cairu')
const other = await fixtures.getMunicipality('feira-de-santana')
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const scopeToken = fixtures.value('scope-demanda')
const inScope = await fixtures.createCampaignDemand({
municipality: administered,
title: `Material ${scopeToken} Cairu`,
createdBy: coordinator,
})
const outOfScope = await fixtures.createCampaignDemand({
municipality: other,
title: `Material ${scopeToken} Feira`,
createdBy: coordinator,
})
const advisorResult = await searchHomeDemands(payload, advisor, scopeToken)
expect(advisorResult.map((hit) => hit.id)).toEqual([inScope.id])
const coordinatorResult = await searchHomeDemands(payload, coordinator, scopeToken)
expect(coordinatorResult.map((hit) => hit.id).sort((a, b) => a - b)).toEqual(
[inScope.id, outOfScope.id].sort((a, b) => a - b),
)
})
it('rejects leaders from home search', async () => {
const fixtures = campaignFixtures()
const leader = await fixtures.createCampaignUser('leader')
await expect(searchHomeDemands(payload, leader, 'Material')).rejects.toThrow(
/equipe de campanha/i,
)
})
})

View file

@ -69,8 +69,11 @@ describe('searchHomeLeaderships (B49)', () => {
const other = await fixtures.getMunicipality('feira-de-santana')
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const inScope = await fixtures.createContact({ name: 'Lider Cairu' })
const outOfScope = await fixtures.createContact({ name: 'Lider Feira' })
// Unique token per run: the minimal seed ships leaderships whose contact
// names collide with fixed queries like 'Lider' (word-start match).
const scopeToken = fixtures.value('scope-lideranca')
const inScope = await fixtures.createContact({ name: `${scopeToken} Cairu` })
const outOfScope = await fixtures.createContact({ name: `${scopeToken} Feira` })
const scopedLeadership = await fixtures.createLeadership({
contact: inScope.id,
municipalities: [administered.id],
@ -80,10 +83,10 @@ describe('searchHomeLeaderships (B49)', () => {
municipalities: [other.id],
})
const advisorResult = await searchHomeLeaderships(payload, advisor, 'Lider')
const advisorResult = await searchHomeLeaderships(payload, advisor, scopeToken)
expect(advisorResult.map((hit) => hit.id)).toEqual([scopedLeadership.id])
const coordinatorResult = await searchHomeLeaderships(payload, coordinator, 'Lider')
const coordinatorResult = await searchHomeLeaderships(payload, coordinator, scopeToken)
expect(coordinatorResult.map((hit) => hit.id).sort((a, b) => a - b)).toEqual(
[scopedLeadership.id, otherLeadership.id].sort((a, b) => a - b),
)

View file

@ -26,6 +26,7 @@ const searchResultsValue = (
leaderships: [],
stateDeputies: [],
activities: [],
demands: [],
...data,
},
},