Merge pull request #457 from fsolla/C89-pagina-atualizacoes-feed-de-cards-filtro-criar-modal-bot

C89 — Página Atualizações: feed de cards + filtro + criar (modal / bottom sheet)
This commit is contained in:
Francisco Solla 2026-08-09 00:05:05 -03:00 committed by GitHub
commit e3f6d3f5eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1711 additions and 85 deletions

View file

@ -0,0 +1,169 @@
# Impl: C89 — Página Atualizações: feed de cards + filtro + criar (modal / bottom sheet)
Status: em execução
Atualizado em: 2026-08-08 (aprovado em gate humano 2026-08-08; execução concluída)
Issue: #401
Intenção: docs/plans/feed-atualizacoes-campanha-threads.md
Appetite: herdado ~1,5–2 dias eng — cabe (1 página + 1 modal + 1 action + loaders, sem migration)
## Leitura da intenção
- **Outcome:** `/campanha/atualizacoes` deixa de ser placeholder e vira um feed
operacional da carteira do ator (staff), mais recentes no topo, com filtro
combobox (município, polaridade, urgente, quem criou, texto) e criação
in-place ("+": modal desktop / bottom sheet mobile) reusando o registro
unificado já vivo (C87) — sem reload de página à toa.
- **O que NÃO negociar:** leader lockdown (gate `staff`); assessor só nos
municípios que administra (access existente, fail-closed); card é leitura em
v1 (sem thread — C88); criar segue o formulário unificado, não um segundo
modelo; p/ `adversarySignal` só staff (coordinator/advisor/candidate).
- **O que reavaliar:** não existe "dashboard últimas atualizações de campo" no
código — o precedente real do feed é o `UpdatesTab` do detalhe de município
(`MunicipalityDetailTabs` + `loadMunicipalityUpdatesFeed`). A direção da
intenção continua correta, mas o "precedente a olhar" apontava para um
painel que não existe.
## Abordagem recomendada
```mermaid
flowchart LR
A[atualizacoes/page.tsx server] --> B[requireCampaignPageActor staff]
A --> C[resolveFeedUrl + loadCampaignUpdatesFeed]
C --> D[CampaignUpdatesFilters omnibox + envio]
C --> E[CampaignUpdatesFeed cards]
D --> F["+ abre CreateModal (Dialog desktop / Drawer mobile)"]
F --> G[form action createCampaignUpdatesFormAction]
G --> A
```
**Opções consideradas:** A (novos módulos de URL/omnibox/feed espelhando o split
de municipios) | B (tudo dentro de `municipalityUpdatePageData.ts`) | C (pasta
nova `utilities/updates/`)
**Recomendação:** A — espelhar o split de `municipios` (`*ListUrl` +
`*Omnibox` + `*PageData`), que é o canonical do repo; mantém o contrato de URL
congelável (B18 saved filters) e o page-data por-município intacto.
**Rejeitadas:** B porque mistura leitura por-município e feed da carteira no
mesmo módulo; C porque uma pasta nova para uma página é premature.
### Componentes / mudanças
- **`municipalityUpdateListUrl.ts`** (`src/utilities/municipality/`): estado do
feed — `page`, `q?` (busca de texto), `slugs?` (município),
`polarities?` (boa|neutra|ruim, "todas"→ausente, `parseExhaustiveEnumParam`),
`urgent?` (exclusivo), `authors?` (ids de campaignUser). Parse/serialize
canônicos via `campaignListUrl` (`resolveListUrl`, `buildListHref`,
`strictDecimalInteger`, `normalizedText`), `buildCampaignUpdatesWhere(state)`
→ `Where` (body contains / municipality in / polarity in / urgent equals /
author in e `and`), `buildCampaignUpdatesHref` (base `/campanha/atualizacoes`).
- **`municipalityUpdateOmnibox.ts`** (`src/utilities/municipality/`): adapter
fiel a `municipalityOmnibox` — chips (Busca/Município/Polaridade/Urgente/Autor),
seeds agrupados (Município, Polaridade, Urgente, Autor, Busca), apply/remove/
clear, `withPageReset`. Reusa `municipalityFilterOptionsForSlugs`
(label via `municipalityCatalog`).
- **`campaignUpdatesFeedData.ts`** (`src/utilities/municipality/`): novo view
model do card (id, body, polarity, urgent, adversarySignal, createdAt,
authorName, authorAvatarUrl, municipalitySlug+Name) e:
- `loadCampaignUpdatesFeed(payload, user, state)` — `payload.find` em
`municipalityUpdate` `sort:-createdAt`, `depth:0`, `user`,
`overrideAccess:false`, **sem** cláusula de município: o access
`canReadMunicipalityUpdate` (scoped-read) já restringe à carteira do
assessor. Pós-processa nomes/avatars (novo `loadCampaignUserDisplayByIds`)
e rótulos de município (`loadMunicipalityLabelsByIds`).
- `loadCampaignUpdatesFeedFacets(payload, user)` — slugs de municípios da
carteira (unrestricted → todos do catálogo; advisor → `getAccessibleMunicipalityIds`
→ slugs) e autores presentes na carteira (find seletivo `author`,
`limit:0`, rotulado por `loadCampaignUserNamesByIds`).
- **`loadCampaignUserDisplayByIds`** (`src/utilities/loadNamesByIds.ts`):
extensão do "resolve names" — id → `{ name, avatarUrl }` (depth 1 +
`select:{name:true, avatar:true}`, `mediaDocumentUrl`). Dono certo: o módulo
de resolução de nomes; mantém `loadCampaignUserNamesByIds` intacto.
- **`CampaignUpdatesFeed.tsx`** (+ card): feed de cards responsivo. Card:
`CampaignUserAvatar` no canto superior esquerdo, body, badges polaridade/
urgente/adversário (variants já usadas em `MunicipalityUpdateFeed`), rodapé
autor + município + data (`Intl.DateTimeFormat` pt-BR). Card é só leitura
(sem link/thread — C88).
- **`CampaignUpdatesFilters.tsx`** (client): `CampaignListOmnibox` + trailing
`Button` "+ Nova atualização"; `useCampaignListFilterNavigation` +
`CampaignListPendingBoundary`/`CampaignListResults` (mesmo esqueleto de
transição de municípios).
- **`CampaignUpdatesCreateModal.tsx`** (client): híbrido
`Dialog`(desktop)/`Drawer`(mobile) via `useIsMobile` — precedente
`CampaignQuickActionsOverlay`; corpo = combobox de município
(`StrictCombobox` sobre slugs acessíveis, label via catálogo) +
`MunicipalityUpdateFields` (reuso do form unificado C87) + `useActionState`.
- **`createFormActions.ts`** (`atualizacoes/`): action fina —
`runCampaignFormAction` → parse → `createMunicipalityUpdate` →
`revalidatePath('/campanha/atualizacoes')`. Extrai o parse de
`updateFormActions.ts` para um helper compartilhado
(`parseMunicipalityUpdateForm(formData)`) — 2º call site, DRY ganha.
- **`atualizacoes/page.tsx`**: substitui o placeholder; `resolveCampaignUpdatesUrl`
(+redirect canônico), `gate:'staff'`, feed + facetas + footer
(`CampaignListFooter` com total/página).
- **Migration:** nenhuma — filtros todos em campos já indexados
(`municipality`, `author`, `polarity`, `urgent` já têm index).
- **Access / Consent:** nenhum novo. Reuso total de `canReadMunicipalityUpdate`
(scoped-read) e `createMunicipalityUpdate` (transação + `adversarySignal`
staff-only via `isCampaignStaff`). Leader não alcança a página (gate staff).
### Dados → forma (se aplicável)
Feed operacional cronológico: cards, mais recentes no topo, sem agregação
estadual — leitura relativa à carteira (restrição da intenção). Sem % absoluto.
## Fases verificáveis
1. **Tracer / pure (server):** `municipalityUpdateListUrl` + `municipalityUpdateOmnibox`
- unit specs (canonicalização, "todas polaridades→ausente", page clamp,
chips/sugestões/apply/remove) — `pnpm gate:fast`.
2. **Loader + view model:** `campaignUpdatesFeedData` +
`loadCampaignUserDisplayByIds`; verificação por int spec leve do loader
(carteira de advisor) se existir precedente análogo — senão unit puro.
3. **UI:** card feed → filtros (omnibox + "+") → modal/sheet + form →
page. Impeccable D: shape → craft → critique → polish.
4. **Gates:** `pnpm gate:fast` na iteração; `pnpm push` na entrega; e2e do
fluxo criar+filtro se a suíte cobrir `/campanha` (verificar).
## Rabbit holes / Não escopo (engenharia)
- **2º formulário de criação** — cortado: `MunicipalityUpdateFields` +
combobox de município, mesma action de escritura.
- **Busca de texto em autor/município** — cortado: `q` cobre `body` só;
gatilho de revisita se o usuário pedir busca ampla.
- **Thread/card navegável** — C88; card read-only.
- **Analytics / scroll infinito / KPI** — não escopo da intenção.
- **Saved filters (B18)** — não desta fatia; o contrato de URL já fica
canônico para servir de base.
### Adiado com gatilho (triage 2026-08-08)
- **Promover `parseSlugsParam`/`parseAuthorsParam` para `campaignListUrl`**
(gémeos dos helpers privados do `municipalityListUrl`; DRY 2 call sites).
Gatilho: "quando um 3º `*ListUrl` precisar do mesmo parse de slugs/ids".
- Descartados (cheap_polish): `isStaff` constante na rota; copy do empty-state
em função de filtro ativo; `loading.tsx` da rota; `revalidatePath` no-op;
dedup manual pré-existente no `municipalityUpdatePageData`.
## Riscos e mitigação
- **`contains` em `textarea` (body):** campo text no Postgres; mesmo caret de
`name:{contains}` das listas — verificar num int/unit do `where`.
- **Feed com access por carteira:** `overrideAccess:false` + `user` é o mesmo
caminho do `UpdatesTab`; facets de autor/município usam o canal admin
justificado (ids já autorizados / slugs da carteira).
- **Modal + server action:** `revalidatePath('/campanha/atualizacoes')`
explícito na action para o card novo subir ao topo; estado de filtro preservado
(searchParams intactos). **Achado real (2026-08-08):** o modal de criação não
pode ser filho React do `<form role="search">` dos filtros — o React 19 não
liga progressive enhancement (`$ACTION_ID`) num action-form aninhado em outro
`<form>` na árvore de componentes (nenhum POST disparava; verificado em dev E
build de produção). Fix: o modal é irmão do form de filtros (wrap em `<div>`,
`CampaignUpdatesFilters.tsx`).
- **435 municípios no combobox:** client-side, slugs→catálogo (padrão B16+);
`StrictCombobox` filtra no word-start — sem carga no servidor.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto (feed + filtro 5 dimensões + criar modal/sheet)
- [ ] Invariantes AGENTS/engineering-standards (gate staff, access fail-closed, form unificado)
- [ ] Testes de domínio previstos (unit URL/omnibox; loader leve)
- [ ] Sem migration; `generate:importmap` se novo componente client (colocar em `components.json` se necessário)

View file

@ -0,0 +1,30 @@
'use server'
import { revalidatePath } from 'next/cache'
import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate'
import {
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import { parseMunicipalityUpdateFormData } from '@/utilities/municipality/municipalityUpdateFormData'
/**
* C89 feed-page create action: the same unified MunicipalityUpdate writer as
* the municipality detail form, revalidating only this feed route so the new
* card surfaces at the top (no full page reload). The actor's access is the
* same `createMunicipalityUpdate` transaction, which enforces `municipality`
* scope and strips `adversarySignal` for non-staff.
*/
export const createCampaignUpdatesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await createMunicipalityUpdate(parseMunicipalityUpdateFormData(formData))
revalidatePath('/campanha/atualizacoes')
return { message: 'Atualização registrada com sucesso.' }
},
genericMessage: 'Não foi possível registrar a atualização. Verifique o município e seu acesso.',
})

View file

@ -1,31 +1,70 @@
import { Inbox } from 'lucide-react'
import config from '@payload-config'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignUpdatesFeed } from '@/components/campaign/municipality/CampaignUpdatesFeed'
import { CampaignUpdatesFilters } from '@/components/campaign/municipality/CampaignUpdatesFilters'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
loadCampaignUpdatesFeed,
loadCampaignUpdatesFeedFacets,
} from '@/utilities/municipality/campaignUpdatesFeedData'
import {
buildCampaignUpdatesFeedHref,
resolveCampaignUpdatesFeedUrl,
} from '@/utilities/municipality/municipalityUpdateListUrl'
export const metadata = campaignPageMetadataFromCatalog('atualizacoes')
/**
* B164 placeholder — staff-only. The `Atualizações` nav item points here so
* the bottom nav has a destination to route to; the content is an honest sign
* that the feed is not built yet (no fake skeleton rows).
*/
export default async function CampaignUpdatesPage() {
await requireCampaignPageActor({ gate: 'staff' })
type CampaignUpdatesPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function CampaignUpdatesPage({ searchParams }: CampaignUpdatesPageProps) {
const rawSearchParams = await searchParams
const canonicalUrl = resolveCampaignUpdatesFeedUrl(rawSearchParams)
if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref)
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff' }),
getPayload({ config }),
])
const isStaff = isCampaignStaff(user)
const { state } = canonicalUrl
const [feed, facets] = await Promise.all([
loadCampaignUpdatesFeed(payload, user, state),
loadCampaignUpdatesFeedFacets(payload, user),
])
const resolvedUrl = resolveCampaignUpdatesFeedUrl(rawSearchParams, feed.totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state: canonicalState } = resolvedUrl
return (
<CampaignPageShell className="items-center py-12">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-muted">
<Inbox aria-hidden="true" className="size-6 text-muted-foreground" />
</div>
<h1 className="text-xl font-semibold">Atualizações em breve</h1>
<p className="max-w-sm text-sm text-muted-foreground">
O painel de atualizações da campanha ainda está em construção. Volte depois para conferir
as novidades.
</p>
</div>
<CampaignPageShell>
<CampaignListPendingBoundary>
<CampaignUpdatesFilters state={canonicalState} facets={facets} isStaff={isStaff} />
<CampaignListResults>
<CampaignUpdatesFeed cards={feed.cards} />
<CampaignListFooter
totalDocs={feed.totalDocs}
singular="atualização encontrada"
plural="atualizações encontradas"
page={feed.page}
totalPages={feed.totalPages}
hrefForPage={(page) => buildCampaignUpdatesFeedHref(canonicalState, page)}
/>
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -3,26 +3,11 @@
import { revalidatePath } from 'next/cache'
import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate'
import {
optionalIntegerFormValue,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
MUNICIPALITY_UPDATE_POLARITY_REQUIRED_MESSAGE,
parseMunicipalityUpdatePolarity,
} from '@/lib/schemas/municipalityUpdate'
import {
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
/** Checkbox pairs with hidden "false"; last value wins (same as leadership exclusive). */
const formBoolean = (formData: FormData, field: string): boolean => {
const values = formData.getAll(field)
if (values.length === 0) return false
return values.at(-1) === 'true'
}
import { parseMunicipalityUpdateFormData } from '@/utilities/municipality/municipalityUpdateFormData'
export const createMunicipalityUpdateFormAction = async (
_state: CampaignFormActionState,
@ -30,21 +15,7 @@ export const createMunicipalityUpdateFormAction = async (
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const polarity = parseMunicipalityUpdatePolarity(requiredFormText(formData, 'polarity'))
if (!polarity) {
throw new Error(MUNICIPALITY_UPDATE_POLARITY_REQUIRED_MESSAGE)
}
await createMunicipalityUpdate({
municipality,
body: requiredFormText(formData, 'body'),
polarity,
urgent: formBoolean(formData, 'urgent'),
activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'),
newSupports: optionalIntegerFormValue(formData, 'newSupports'),
adversarySignal: formBoolean(formData, 'adversarySignal'),
})
await createMunicipalityUpdate(parseMunicipalityUpdateFormData(formData))
revalidatePath('/campanha/municipios/[slug]', 'page')
return { message: 'Atualização registrada com sucesso.' }
},

View file

@ -14,7 +14,6 @@ import {
import {
nullableFormText,
optionalFormText,
optionalIntegerFormValue,
optionalMunicipalitySlugFromForm,
repeatedRelationshipFormValues,
requiredFormBoolean,
@ -28,10 +27,6 @@ import {
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
parsePoliticalTrendStatusFormValue,
} from '@/lib/schemas/municipality'
import {
MUNICIPALITY_UPDATE_POLARITY_REQUIRED_MESSAGE,
parseMunicipalityUpdatePolarity,
} from '@/lib/schemas/municipalityUpdate'
import {
STATE_DEPUTY_CONFLICT_MESSAGE,
STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
@ -43,6 +38,7 @@ import {
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
import { parseMunicipalityUpdateFormData } from '@/utilities/municipality/municipalityUpdateFormData'
import { municipalityStaffEditSafeMessages } from './municipalityStaffEditMessages'
@ -104,34 +100,13 @@ export const assignMunicipalityAdvisorsFormAction = async (
'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
})
/** Checkbox pairs with hidden "false"; last value wins (same as leadership exclusive). */
const formBoolean = (formData: FormData, field: string): boolean => {
const values = formData.getAll(field)
if (values.length === 0) return false
return values.at(-1) === 'true'
}
export const createMunicipalityListUpdateFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const polarity = parseMunicipalityUpdatePolarity(requiredFormText(formData, 'polarity'))
if (!polarity) {
throw new Error(MUNICIPALITY_UPDATE_POLARITY_REQUIRED_MESSAGE)
}
await createMunicipalityUpdate({
municipality,
body: requiredFormText(formData, 'body'),
polarity,
urgent: formBoolean(formData, 'urgent'),
activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'),
newSupports: optionalIntegerFormValue(formData, 'newSupports'),
adversarySignal: formBoolean(formData, 'adversarySignal'),
})
await createMunicipalityUpdate(parseMunicipalityUpdateFormData(formData))
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { message: 'Atualização registrada.' }
},

View file

@ -0,0 +1,150 @@
'use client'
import { useActionState, useEffect, useMemo, useState } from 'react'
import { createCampaignUpdatesFormAction } from '@/app/(campaign)/campanha/(app)/atualizacoes/createFormActions'
import { MunicipalityUpdateFields } from '@/components/campaign/municipality/MunicipalityUpdateFields'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/ui/Drawer'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Spinner } from '@/components/ui/Spinner'
import { useIsMobile } from '@/hooks/use-mobile'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import { fieldError } from '@/utilities/campaignFormFields'
import type { CampaignUpdatesFeedFacets } from '@/utilities/municipality/campaignUpdatesFeedData'
type CampaignUpdatesCreateModalProps = {
open: boolean
onOpenChange: (open: boolean) => void
municipalities: CampaignUpdatesFeedFacets['municipalities']
isStaff: boolean
/** Municipality slug to prefill when exactly one is active in the filter. */
prefillSlug?: string
}
const MUNICIPALITY_COMBOBOX_ID = 'campaign-updates-create-municipality'
/**
* The modal/sheet create surface for the updates feed (C89). One component
* owns `useActionState` and renders the `<form>`, mirroring the proven
* dialog-action pattern (ActivityLifecycleDialog) — the server action needs to
* be bound where the form mounts for progressive enhancement to attach.
*/
export const CampaignUpdatesCreateModal = ({
open,
onOpenChange,
municipalities,
isStaff,
prefillSlug,
}: CampaignUpdatesCreateModalProps) => {
const isMobile = useIsMobile()
const [state, submitAction, isPending] = useActionState(createCampaignUpdatesFormAction, {})
const options = useMemo(
() =>
municipalities.map(({ id, slug }) => ({
value: String(id),
label: getMunicipalityCatalogEntry(slug)?.name ?? slug,
})),
[municipalities],
)
const initialMunicipalityId = useMemo(() => {
if (!prefillSlug) return ''
const match = municipalities.find((municipality) => municipality.slug === prefillSlug)
return match ? String(match.id) : ''
}, [municipalities, prefillSlug])
const [municipalityId, setMunicipalityId] = useState(initialMunicipalityId)
// The modal stays mounted (sibling of the filters form), so the municipality
// must re-sync to the active filter's prefill on every open — otherwise a
// stale selection would be silently submitted.
useEffect(() => {
if (open) setMunicipalityId(initialMunicipalityId)
}, [open, initialMunicipalityId])
useCampaignFormSuccessToast(state, () => onOpenChange(false))
const municipalityError = fieldError(state.fieldErrors, 'municipalityId')
const createForm = (
<form action={submitAction} className="flex flex-col gap-4">
<input type="hidden" name="municipalityId" value={municipalityId} />
<Field>
<FieldLabel htmlFor={MUNICIPALITY_COMBOBOX_ID}>Município</FieldLabel>
<FieldDescription>
O registro fica vinculado a um município da sua carteira.
</FieldDescription>
<StrictCombobox
id={MUNICIPALITY_COMBOBOX_ID}
options={options}
value={municipalityId}
onValueChange={setMunicipalityId}
error={municipalityError}
/>
{municipalityError ? (
<FieldError id={`${MUNICIPALITY_COMBOBOX_ID}-error`}>{municipalityError}</FieldError>
) : null}
</Field>
<MunicipalityUpdateFields
idPrefix="campaign-updates-create"
fieldErrors={state.fieldErrors}
isStaff={isStaff}
/>
{state.status !== 'success' ? (
<CampaignFormActionMessage
state={state}
errorTitle="Não foi possível registrar"
successFallbackMessage="Atualização registrada."
/>
) : null}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
className="min-h-11"
disabled={isPending}
onClick={() => onOpenChange(false)}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending} className="min-h-11">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Registrar atualização
</Button>
</div>
</form>
)
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange} showSwipeHandle>
<DrawerContent className="max-h-[90dvh] border-t border-border bg-background text-foreground">
<DrawerTitle className="mb-3 text-base font-medium">Nova atualização</DrawerTitle>
<DrawerDescription className="sr-only">
Registre um novo fato de campo em um município
</DrawerDescription>
<div className="flex flex-col overflow-y-auto px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-3">
{createForm}
</div>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85dvh] w-[calc(100vw-2rem)] max-w-lg overflow-y-auto p-4 sm:p-6">
<DialogTitle>Nova atualização</DialogTitle>
<DialogDescription>Registre um novo fato de campo em um município.</DialogDescription>
{createForm}
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,63 @@
import { Inbox } from 'lucide-react'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignUserAvatar } from '@/components/campaign/shared/CampaignUserAvatar'
import { Badge } from '@/components/ui/Badge'
import {
municipalityUpdatePolarityBadgeVariant,
municipalityUpdatePolarityLabels,
} from '@/lib/schemas/municipalityUpdate'
import type { CampaignUpdatesFeedCard } from '@/utilities/municipality/campaignUpdatesFeedData'
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'short',
timeStyle: 'short',
})
const CampaignUpdatesFeedItem = ({ card }: { card: CampaignUpdatesFeedCard }) => (
<li className="flex gap-3 rounded-xl border p-4">
<CampaignUserAvatar
name={card.author.name}
avatarUrl={card.author.avatarUrl}
size="default"
className="mt-0.5 size-10 shrink-0"
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<p className="whitespace-pre-wrap text-sm">{card.body ?? 'Sem texto.'}</p>
<div className="flex flex-wrap gap-1.5">
<Badge variant={municipalityUpdatePolarityBadgeVariant[card.polarity]}>
{municipalityUpdatePolarityLabels[card.polarity]}
</Badge>
{card.urgent ? <Badge variant="destructive">Urgente</Badge> : null}
{card.adversarySignal ? <Badge variant="outline">Adversário</Badge> : null}
</div>
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{card.author.name}</span>
<span aria-hidden="true"> · </span>
{card.municipality.name}
<span aria-hidden="true"> · </span>
{dateTimeFormatter.format(new Date(card.createdAt))}
</p>
</div>
</li>
)
export const CampaignUpdatesFeed = ({ cards }: { cards: CampaignUpdatesFeedCard[] }) => {
if (cards.length === 0) {
return (
<CampaignListEmptyState
icon={Inbox}
title="Nenhuma atualização encontrada"
description="Ajuste o filtro ou registre um novo fato com o botão “+ Nova atualização”."
/>
)
}
return (
<ul className="flex flex-col gap-4">
{cards.map((card) => (
<CampaignUpdatesFeedItem key={card.id} card={card} />
))}
</ul>
)
}

View file

@ -0,0 +1,147 @@
'use client'
import { Plus } from 'lucide-react'
import { useMemo, useState } from 'react'
import { CampaignUpdatesCreateModal } from '@/components/campaign/municipality/CampaignUpdatesCreateModal'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { Button } from '@/components/ui/button'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import type { CampaignUpdatesFeedFacets } from '@/utilities/municipality/campaignUpdatesFeedData'
import {
buildCampaignUpdatesFeedHref,
type CampaignUpdatesFeedState,
} from '@/utilities/municipality/municipalityUpdateListUrl'
import {
applyCampaignUpdatesFeedSuggestion,
buildCampaignUpdatesFeedChips,
buildCampaignUpdatesFeedSuggestionSeeds,
clearCampaignUpdatesFeedFilters,
filterCampaignUpdatesFeedSuggestions,
removeCampaignUpdatesFeedChip,
type CampaignUpdatesFeedOmniboxAction,
} from '@/utilities/municipality/municipalityUpdateOmnibox'
type CampaignUpdatesFiltersProps = {
state: CampaignUpdatesFeedState
facets: CampaignUpdatesFeedFacets
isStaff: boolean
}
export const CampaignUpdatesFilters = ({ state, facets, isStaff }: CampaignUpdatesFiltersProps) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) => buildCampaignUpdatesFeedHref(next, 1),
})
const [query, setQuery] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const municipalitySlugOptions = useMemo(
() => facets.municipalities.map((municipality) => municipality.slug),
[facets.municipalities],
)
const municipalityNameBySlug = useMemo(
() =>
new Map(
facets.municipalities.map(({ slug }) => [
slug,
getMunicipalityCatalogEntry(slug)?.name ?? slug,
]),
),
[facets.municipalities],
)
const authorNameById = useMemo(
() => new Map(facets.authorOptions.map((option) => [Number(option.value), option.label])),
[facets.authorOptions],
)
const chips = useMemo(
() =>
buildCampaignUpdatesFeedChips({
state,
municipalityNameBySlug,
authorNameById,
}),
[state, municipalityNameBySlug, authorNameById],
)
const suggestionSeeds = useMemo(
() =>
buildCampaignUpdatesFeedSuggestionSeeds({
municipalitySlugOptions,
authorOptions: facets.authorOptions,
}),
[municipalitySlugOptions, facets.authorOptions],
)
const suggestions = useMemo(
() => filterCampaignUpdatesFeedSuggestions(suggestionSeeds, query),
[suggestionSeeds, query],
)
const runAction = (action: CampaignUpdatesFeedOmniboxAction) => {
if (action.kind === 'clear') {
setQuery('')
navigate(action.state)
return
}
navigate(action.state)
}
const prefillSlug = state.slugs?.length === 1 ? state.slugs[0] : undefined
return (
<>
<form
role="search"
onSubmit={(event) => {
event.preventDefault()
}}
>
<CampaignListOmnibox
id="campaign-updates-omnibox"
label="Filtrar atualizações"
placeholder="Digite para filtrar (município, polaridade, autor…)"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(applyCampaignUpdatesFeedSuggestion({ state, suggestionId }))
}}
onCommitQuery={(text) => {
runAction(applyCampaignUpdatesFeedSuggestion({ state, suggestionId: `q:${text}` }))
}}
onRemoveChip={(chipId) => {
runAction(removeCampaignUpdatesFeedChip({ state, chipId }))
}}
onClearAll={() => {
runAction(clearCampaignUpdatesFeedFilters())
}}
trailing={
<Button
type="button"
variant="outline"
className="min-h-11 shrink-0 gap-2"
onClick={() => setCreateOpen(true)}
>
<Plus aria-hidden="true" />
Nova atualização
</Button>
}
/>
</form>
<CampaignUpdatesCreateModal
open={createOpen}
onOpenChange={setCreateOpen}
municipalities={facets.municipalities}
isStaff={isStaff}
prefillSlug={prefillSlug}
/>
</>
)
}

View file

@ -1,5 +1,8 @@
import { Badge } from '@/components/ui/Badge'
import { municipalityUpdatePolarityLabels } from '@/lib/schemas/municipalityUpdate'
import {
municipalityUpdatePolarityBadgeVariant,
municipalityUpdatePolarityLabels,
} from '@/lib/schemas/municipalityUpdate'
import type { MunicipalityUpdateViewModel } from '@/utilities/municipality/municipalityUpdatePageData'
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
@ -9,12 +12,6 @@ const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
const numberFormatter = new Intl.NumberFormat('pt-BR')
const polarityVariant = {
boa: 'default',
neutra: 'secondary',
ruim: 'destructive',
} as const
export const MunicipalityUpdateFeed = ({ updates }: { updates: MunicipalityUpdateViewModel[] }) => {
if (!updates.length) {
return (
@ -29,7 +26,7 @@ export const MunicipalityUpdateFeed = ({ updates }: { updates: MunicipalityUpdat
{updates.map((update) => (
<li key={update.id} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={polarityVariant[update.polarity]}>
<Badge variant={municipalityUpdatePolarityBadgeVariant[update.polarity]}>
{municipalityUpdatePolarityLabels[update.polarity]}
</Badge>
{update.urgent ? <Badge variant="destructive">Urgente</Badge> : null}

View file

@ -11,6 +11,16 @@ export const municipalityUpdatePolarityLabels: Record<MunicipalityUpdatePolarity
ruim: 'Ruim',
}
/** Polarity → Badge variant, single-sourced for every feed that renders it. */
export const municipalityUpdatePolarityBadgeVariant: Record<
MunicipalityUpdatePolarity,
'default' | 'secondary' | 'destructive'
> = {
boa: 'default',
neutra: 'secondary',
ruim: 'destructive',
}
export const MUNICIPALITY_UPDATE_POLARITY_REQUIRED_MESSAGE = 'Informe a polaridade da atualização.'
export const MUNICIPALITY_UPDATE_BODY_REQUIRED_MESSAGE = 'Informe o texto da atualização.'

View file

@ -2,6 +2,8 @@ import 'server-only'
import type { Payload } from 'payload'
import { mediaDocumentUrl } from '@/utilities/campaignUserProfile'
/**
* Display-name resolution by id set (Pass 3 P3-E). The "resolve names for rows
* the actor already passed row-level access on" pattern used to be re-spelled
@ -32,6 +34,31 @@ export const loadCampaignUserNamesByIds = async (
return new Map(result.docs.map((doc) => [doc.id, doc.name]))
}
/**
* Campaign user id → `{ name, avatarUrl }` for surfaces that render an author
* avatar (C89 updates feed). Same justified admin bypass as
* `loadCampaignUserNamesByIds`; the avatar relationship is populated at depth 1
* so `mediaDocumentUrl` can resolve the media url.
*/
export const loadCampaignUserDisplayByIds = async (
payload: Pick<Payload, 'find'>,
ids: readonly number[],
): Promise<Map<number, { name: string; avatarUrl: string | null }>> => {
if (ids.length === 0) return new Map()
const result = await payload.find({
collection: 'campaignUser',
where: { id: { in: [...ids] } },
depth: 1,
limit: 0,
pagination: false,
select: { name: true, avatar: true },
overrideAccess: true,
})
return new Map(
result.docs.map((doc) => [doc.id, { name: doc.name, avatarUrl: mediaDocumentUrl(doc.avatar) }]),
)
}
/** Municipality id → `{ name, slug }` — both travel together on every list/detail label. */
export const loadMunicipalityLabelsByIds = async (
payload: Pick<Payload, 'find'>,

View file

@ -0,0 +1,161 @@
import 'server-only'
import type { Payload } from 'payload'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import {
municipalityUpdatePolarities,
type MunicipalityUpdatePolarity,
} from '@/lib/schemas/municipalityUpdate'
import type { CampaignUser, MunicipalityUpdate } from '@/payload-types'
import {
loadCampaignUserDisplayByIds,
loadCampaignUserNamesByIds,
loadMunicipalityLabelsByIds,
} from '@/utilities/loadNamesByIds'
import {
buildCampaignUpdatesFeedWhere,
campaignUpdatesFeedPageSize,
type CampaignUpdatesFeedState,
} from '@/utilities/municipality/municipalityUpdateListUrl'
export type CampaignUpdatesFeedCard = {
id: number
body: string | null
polarity: MunicipalityUpdatePolarity
urgent: boolean
/**
* Shown to every feed reader without a read-side role check. Safe because
* the page gate is `staff` and `canReadMunicipalityUpdate` denies leaders
* outright — no lesser-privileged reader exists yet. Re-check before any
* non-staff surface reuses the loader.
*/
adversarySignal: boolean
createdAt: string
author: { id: number; name: string; avatarUrl: string | null }
municipality: { id: number; name: string; slug: string }
}
export type CampaignUpdatesFeedFacets = {
/**
* Municipalities the actor may filter by / create updates in — the whole
* portfolio (advisor: administered; unrestricted: all 435). Carries both id
* (create combobox value) and slug (omnibox seed; labels come from the
* catalog on the client).
*/
municipalities: Array<{ id: number; slug: string }>
/** "Quem criou" options: distinct authors present in the actor's scope. */
authorOptions: Array<{ value: string; label: string }>
}
/**
* The C89 feed query runs with `overrideAccess: false` and NO municipality
* clause: `canReadMunicipalityUpdate` (scoped read) restricts the result to
* the actor's portfolio — advisor → administered municipalities, unrestricted →
* everything, leader → nothing. The page's `gate: 'staff'` keeps leaders out.
* The municipality filter, when present, goes through the relationship's own
* `slug` field (dotted path) so an out-of-scope slug returns zero rows.
*/
export const loadCampaignUpdatesFeed = async (
payload: Payload,
user: CampaignUser,
state: CampaignUpdatesFeedState,
): Promise<{
cards: CampaignUpdatesFeedCard[]
totalDocs: number
totalPages: number
page: number
}> => {
const result = await payload.find({
collection: 'municipalityUpdate',
where: buildCampaignUpdatesFeedWhere(state),
depth: 0,
limit: campaignUpdatesFeedPageSize,
page: state.page,
sort: '-createdAt',
user,
overrideAccess: false,
})
const authorIDs = uniqueRelationshipIds(result.docs.map((update) => update.author))
const municipalityIDs = uniqueRelationshipIds(result.docs.map((update) => update.municipality))
const [authorDisplayById, municipalityLabelsById] = await Promise.all([
loadCampaignUserDisplayByIds(payload, authorIDs),
loadMunicipalityLabelsByIds(payload, municipalityIDs),
])
const cards = result.docs.map((update): CampaignUpdatesFeedCard => {
const doc = update as MunicipalityUpdate
const authorID = relationshipId(doc.author) ?? 0
const municipalityID = relationshipId(doc.municipality) ?? 0
const author = authorDisplayById.get(authorID)
const municipality = municipalityLabelsById.get(municipalityID)
const polarity = doc.polarity as MunicipalityUpdatePolarity | undefined
return {
id: doc.id,
body: doc.body ?? null,
polarity: polarity && municipalityUpdatePolarities.includes(polarity) ? polarity : 'neutra',
urgent: Boolean(doc.urgent),
adversarySignal: Boolean(doc.adversarySignal),
createdAt: doc.createdAt,
author: {
id: authorID,
name: author?.name ?? 'Usuário',
avatarUrl: author?.avatarUrl ?? null,
},
municipality: municipality
? { id: municipalityID, name: municipality.name, slug: municipality.slug }
: { id: municipalityID, name: 'Município', slug: '' },
}
})
return {
cards,
totalDocs: result.totalDocs,
totalPages: result.totalPages,
page: result.page ?? state.page,
}
}
/** Options for the feed omnibox and create form, scoped to the actor's portfolio. */
export const loadCampaignUpdatesFeedFacets = async (
payload: Payload,
user: CampaignUser,
): Promise<CampaignUpdatesFeedFacets> => {
const [municipalityResult, authorResult] = await Promise.all([
payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
select: { id: true, slug: true },
user,
overrideAccess: false,
}),
payload.find({
collection: 'municipalityUpdate',
depth: 0,
limit: 0,
pagination: false,
select: { author: true },
user,
overrideAccess: false,
}),
])
const municipalities = municipalityResult.docs
.map(({ id, slug }) => ({ id, slug }))
.sort((left, right) => left.slug.localeCompare(right.slug, 'pt-BR'))
const authorIDs = uniqueRelationshipIds(authorResult.docs.map((doc) => doc.author))
const authorNameById = await loadCampaignUserNamesByIds(payload, authorIDs)
return {
municipalities,
authorOptions: authorIDs
.map((id) => ({ id, name: authorNameById.get(id) ?? `Pessoa #${id}` }))
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
.map(({ id, name }) => ({ value: String(id), label: name })),
}
}

View file

@ -0,0 +1,45 @@
import 'server-only'
import {
optionalIntegerFormValue,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
MUNICIPALITY_UPDATE_POLARITY_REQUIRED_MESSAGE,
parseMunicipalityUpdatePolarity,
type MunicipalityUpdateCreateInput,
} from '@/lib/schemas/municipalityUpdate'
/** Checkbox pairs with hidden "false"; last value wins (same as leadership exclusive). */
const formBoolean = (formData: FormData, field: string): boolean => {
const values = formData.getAll(field)
if (values.length === 0) return false
return values.at(-1) === 'true'
}
/**
* Shared parse step for the two MunicipalityUpdate create surfaces (the
* municipality detail form and the C89 feed page modal): FormData → the
* `MunicipalityUpdateCreateInput` the server action consumes. Throws
* `FormDataBoundaryError` for boundary-visible field problems, exactly like
* the hand-rolled ladder in `updateFormActions.ts` used to.
*/
export const parseMunicipalityUpdateFormData = (
formData: FormData,
): MunicipalityUpdateCreateInput => {
const polarity = parseMunicipalityUpdatePolarity(requiredFormText(formData, 'polarity'))
if (!polarity) {
throw new Error(MUNICIPALITY_UPDATE_POLARITY_REQUIRED_MESSAGE)
}
return {
municipality: requiredRelationshipFormValue(formData, 'municipalityId'),
body: requiredFormText(formData, 'body'),
polarity,
urgent: formBoolean(formData, 'urgent'),
activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'),
newSupports: optionalIntegerFormValue(formData, 'newSupports'),
adversarySignal: formBoolean(formData, 'adversarySignal'),
}
}

View file

@ -0,0 +1,182 @@
/**
* Campaign updates feed URL contract (C89): state type, param
* parsing/canonicalization, Payload `where` and serialization — the page-level
* counterpart of `municipalityListUrl.ts` for the read-only feed of updates
* across the actor's portfolio. The URL contract is deliberately canonical and
* frozen-in-shape so a later saved-filters feature (B18 family) can build on it
* without a contract break.
*
* URL params (all optional): `page`, `q` (text search on body), `slug`
* (municipality slugs, multi OR), `polarity` (boa|neutra|ruim, multi OR,
* "todas" canonicalizes to absent), `urgent` (exclusive boolean toggle) and
* `author` (campaignUser ids, multi OR).
*
* The `municipalityUpdate` collection only keeps the `municipality`
* relationship by DB id, but the municipality slugs are filtered through the
* relationship's own `slug` field (dotted path) — pure, fail-closed (an
* unknown slug filters to nothing instead of widening), and the same
* relationship-slug filtering the rest of the codebase uses.
*/
import type { Where } from 'payload'
import { isMunicipalitySlug } from '@/lib/municipalityCatalog'
import {
municipalityUpdatePolarities,
type MunicipalityUpdatePolarity,
} from '@/lib/schemas/municipalityUpdate'
import {
allParamValues,
buildListHref,
firstValue,
normalizedText,
parseExhaustiveEnumParam,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
export const campaignUpdatesFeedPageSize = 20
export type CampaignUpdatesFeedState = {
page: number
/** Free-text search over the update `body` (not author/municipality name). */
q?: string
/** Multi-select (OR) municipality catalog slugs. */
slugs?: string[]
/**
* Multi-select (OR) polarities. Never holds the full set: "todas" is encoded
* as absent, canonicalized by `parseCampaignUpdatesFeedParams`.
*/
polarities?: MunicipalityUpdatePolarity[]
/** Exclusive boolean toggle — true only when filtering urgent updates. */
urgent?: boolean
/** Multi-select (OR) campaignUser author ids. */
authors?: number[]
}
const campaignUpdatesFeedParamNames = ['q', 'slug', 'polarity', 'urgent', 'author', 'page'] as const
const campaignUpdatesFeedParamNameSet = new Set<string>(campaignUpdatesFeedParamNames)
const municipalityUpdatePolaritySet = new Set<string>(municipalityUpdatePolarities)
const parseSlugsParam = (raw: string | string[] | undefined): string[] => {
const slugs: string[] = []
const seen = new Set<string>()
for (const token of allParamValues(raw)) {
if (!isMunicipalitySlug(token) || seen.has(token)) continue
seen.add(token)
slugs.push(token)
}
return slugs
}
const parseAuthorsParam = (raw: string | string[] | undefined): number[] => {
const authors: number[] = []
const seen = new Set<number>()
for (const token of allParamValues(raw)) {
const id = strictDecimalInteger(token)
if (id === undefined || seen.has(id)) continue
seen.add(id)
authors.push(id)
}
return authors.sort((left, right) => left - right)
}
const campaignUpdatesFeedStateToRawParams = (
state: CampaignUpdatesFeedState,
page = state.page,
): RawSearchParams => ({
page: String(page),
q: state.q,
slug: state.slugs,
polarity: state.polarities,
urgent: state.urgent ? 'true' : undefined,
author: state.authors?.map(String),
})
export const parseCampaignUpdatesFeedParams = (
params: RawSearchParams,
): CampaignUpdatesFeedState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const q = normalizedText(firstValue(params.q))
const slugs = parseSlugsParam(params.slug)
const polarities = parseExhaustiveEnumParam<MunicipalityUpdatePolarity>(
params.polarity,
municipalityUpdatePolaritySet,
)
const urgent = firstValue(params.urgent) === 'true'
const authors = parseAuthorsParam(params.author)
return {
page: rawPage ?? 1,
...(q ? { q } : {}),
...(slugs.length ? { slugs } : {}),
...(polarities.length ? { polarities } : {}),
...(urgent ? { urgent } : {}),
...(authors.length ? { authors } : {}),
}
}
/**
* Serializes a state that is ALREADY canonical (came out of
* `parseCampaignUpdatesFeedParams`, or was derived from a canonical state by a
* rule-preserving toggle).
*/
export const serializeCanonicalCampaignUpdatesFeedSearchParams = (
canonicalState: CampaignUpdatesFeedState,
): URLSearchParams => {
const params = new URLSearchParams()
if (canonicalState.q) params.set('q', canonicalState.q)
for (const slug of canonicalState.slugs ?? []) params.append('slug', slug)
for (const polarity of canonicalState.polarities ?? []) params.append('polarity', polarity)
if (canonicalState.urgent) params.set('urgent', 'true')
for (const author of canonicalState.authors ?? []) params.append('author', String(author))
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
return params
}
const buildCampaignUpdatesFeedSearchParams = (
state: CampaignUpdatesFeedState,
page = state.page,
): URLSearchParams =>
serializeCanonicalCampaignUpdatesFeedSearchParams(
parseCampaignUpdatesFeedParams(campaignUpdatesFeedStateToRawParams(state, page)),
)
export const buildCampaignUpdatesFeedHref = (
state: CampaignUpdatesFeedState,
page: number,
): string =>
buildListHref(state, buildCampaignUpdatesFeedSearchParams, '/campanha/atualizacoes', page)
export const resolveCampaignUpdatesFeedUrl = (
params: RawSearchParams,
totalPages?: number,
): {
state: CampaignUpdatesFeedState
href: string
redirectHref?: string
} =>
resolveListUrl({
params,
paramNameSet: campaignUpdatesFeedParamNameSet,
parse: parseCampaignUpdatesFeedParams,
buildSearchParams: buildCampaignUpdatesFeedSearchParams,
basePath: '/campanha/atualizacoes',
totalPages,
})
export const buildCampaignUpdatesFeedWhere = (state: CampaignUpdatesFeedState): Where => {
const filters: Where[] = []
if (state.q) filters.push({ body: { contains: state.q } })
if (state.slugs?.length) filters.push({ 'municipality.slug': { in: state.slugs } })
if (state.polarities?.length) filters.push({ polarity: { in: state.polarities } })
if (state.urgent) filters.push({ urgent: { equals: true } })
if (state.authors?.length) filters.push({ author: { in: state.authors } })
return filters.length ? { and: filters } : {}
}

View file

@ -0,0 +1,263 @@
/**
* Campaign updates feed omnibox adapter (C89): chips, suggestions and
* apply/remove over the frozen URL contract + the shared seed/filter helpers
* from `@/lib/campaignListOmnibox`. Pure / client-safe — mirrors the
* municipality-list adapter, restricted to the five feed dimensions
* (municipality, polarity, urgent, author, text search).
*/
import {
createOmniboxSuggestionSeed,
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import {
municipalityUpdatePolarities,
municipalityUpdatePolarityLabels,
parseMunicipalityUpdatePolarity,
} from '@/lib/schemas/municipalityUpdate'
import { strictDecimalInteger } from '@/utilities/campaignListUrl'
import { municipalityFilterOptionsForSlugs } from '@/utilities/municipality/municipalityListFilters'
import type { CampaignUpdatesFeedState } from '@/utilities/municipality/municipalityUpdateListUrl'
export type CampaignUpdatesFeedOmniboxAction =
| { kind: 'url'; state: CampaignUpdatesFeedState }
| { kind: 'clear'; state: CampaignUpdatesFeedState }
/** Author facet options — `{ value: String(id), label: name }`, server-provided. */
export type CampaignUpdatesFeedAuthorOption = { value: string; label: string }
const withPageReset = (state: CampaignUpdatesFeedState): CampaignUpdatesFeedState => ({
...state,
page: 1,
})
const toggleInArray = <T>(values: readonly T[] | undefined, value: T): T[] | undefined => {
const current = values ?? []
const next = current.includes(value)
? current.filter((entry) => entry !== value)
: [...current, value]
return next.length ? next : undefined
}
export const buildCampaignUpdatesFeedChips = ({
state,
municipalityNameBySlug,
authorNameById,
}: {
state: CampaignUpdatesFeedState
/** Label overrides for a selected slug (from the catalog on the client). */
municipalityNameBySlug: ReadonlyMap<string, string>
/** Label overrides for a selected author id (server-provided name). */
authorNameById: ReadonlyMap<number, string>
}): CampaignListOmniboxChip[] => {
const chips: CampaignListOmniboxChip[] = []
if (state.q) chips.push({ id: 'q', label: `Busca: ${state.q}` })
for (const slug of state.slugs ?? []) {
chips.push({
id: `slug:${slug}`,
label: `Município: ${municipalityNameBySlug.get(slug) ?? slug}`,
})
}
for (const polarity of state.polarities ?? []) {
chips.push({
id: `polarity:${polarity}`,
label: `Polaridade: ${municipalityUpdatePolarityLabels[polarity]}`,
})
}
if (state.urgent) chips.push({ id: 'urgent', label: 'Urgente' })
for (const authorId of state.authors ?? []) {
chips.push({
id: `author:${authorId}`,
label: `Autor: ${authorNameById.get(authorId) ?? `Pessoa #${authorId}`}`,
})
}
return chips
}
/** Stable suggestion catalog for a facet snapshot — memoize across keystrokes. */
export const buildCampaignUpdatesFeedSuggestionSeeds = ({
municipalitySlugOptions,
authorOptions,
}: {
/** Bare slugs; labels come from the catalog on the client (B16+ pattern). */
municipalitySlugOptions: readonly string[]
/** Author facet options — server-computed names. */
authorOptions: readonly CampaignUpdatesFeedAuthorOption[]
}): ReturnType<typeof createOmniboxSuggestionSeed>[] => {
// Small dimension shortcuts surface on focus (empty query) — same as the
// municipality list; the big Município group stays behind a typed query so
// a coordinator never sees 435 names dumped into the popover.
const seeds: ReturnType<typeof createOmniboxSuggestionSeed>[] = [
createOmniboxSuggestionSeed(
{ id: 'urgent', group: 'Urgente', label: 'Urgente' },
{ emptyQueryVisible: true },
),
]
for (const polarity of municipalityUpdatePolarities) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `polarity:${polarity}`,
group: 'Polaridade',
label: municipalityUpdatePolarityLabels[polarity],
keywords: ['polaridade'],
},
{ emptyQueryVisible: true },
),
)
}
for (const option of authorOptions) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `author:${option.value}`,
group: 'Autor',
label: option.label,
keywords: ['autor', 'quem', 'criou'],
},
{ emptyQueryVisible: true },
),
)
}
for (const option of municipalityFilterOptionsForSlugs(municipalitySlugOptions)) {
seeds.push(
createOmniboxSuggestionSeed({
id: `slug:${option.value}`,
group: 'Município',
label: option.label,
keywords: ['municipio', 'município', option.value],
}),
)
}
return seeds
}
/** One-shot convenience for tests; the UI memoizes seeds + filters. */
export const buildCampaignUpdatesFeedSuggestions = ({
query,
municipalitySlugOptions,
authorOptions,
}: {
query: string
municipalitySlugOptions: readonly string[]
authorOptions: readonly CampaignUpdatesFeedAuthorOption[]
}): CampaignListOmniboxSuggestion[] =>
filterOmniboxSuggestionSeeds(
buildCampaignUpdatesFeedSuggestionSeeds({ municipalitySlugOptions, authorOptions }),
query,
)
/** Same filtering on memoized seeds — what the UI calls per keystroke. */
export const filterCampaignUpdatesFeedSuggestions = (
seeds: ReturnType<typeof buildCampaignUpdatesFeedSuggestionSeeds>,
query: string,
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
export const applyCampaignUpdatesFeedSuggestion = ({
state,
suggestionId,
}: {
state: CampaignUpdatesFeedState
suggestionId: string
}): CampaignUpdatesFeedOmniboxAction => {
if (suggestionId.startsWith('q:')) {
const q = suggestionId.slice(2)
return { kind: 'url', state: withPageReset({ ...state, q: q || undefined }) }
}
if (suggestionId === 'urgent') {
return {
kind: 'url',
state: withPageReset({ ...state, urgent: state.urgent ? undefined : true }),
}
}
if (suggestionId.startsWith('slug:')) {
const slug = suggestionId.slice(5)
return {
kind: 'url',
state: withPageReset({ ...state, slugs: toggleInArray(state.slugs, slug) }),
}
}
if (suggestionId.startsWith('polarity:')) {
const polarity = parseMunicipalityUpdatePolarity(suggestionId.slice(9))
if (!polarity) return { kind: 'url', state }
return {
kind: 'url',
state: withPageReset({
...state,
polarities: toggleInArray(state.polarities, polarity),
}),
}
}
if (suggestionId.startsWith('author:')) {
const authorId = strictDecimalInteger(suggestionId.slice(7))
if (!authorId) return { kind: 'url', state }
return {
kind: 'url',
state: withPageReset({ ...state, authors: toggleInArray(state.authors, authorId) }),
}
}
return { kind: 'url', state }
}
export const removeCampaignUpdatesFeedChip = ({
state,
chipId,
}: {
state: CampaignUpdatesFeedState
chipId: string
}): CampaignUpdatesFeedOmniboxAction => {
if (chipId === 'q') return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
if (chipId === 'urgent') {
return { kind: 'url', state: withPageReset({ ...state, urgent: undefined }) }
}
if (chipId.startsWith('slug:')) {
const slug = chipId.slice(5)
const slugs = (state.slugs ?? []).filter((entry) => entry !== slug)
return {
kind: 'url',
state: withPageReset({ ...state, slugs: slugs.length ? slugs : undefined }),
}
}
if (chipId.startsWith('polarity:')) {
const polarities = (state.polarities ?? []).filter((entry) => entry !== chipId.slice(9))
return {
kind: 'url',
state: withPageReset({
...state,
polarities: polarities.length ? polarities : undefined,
}),
}
}
if (chipId.startsWith('author:')) {
const raw = chipId.slice(7)
const authorId = /^[1-9]\d*$/.test(raw) ? Number(raw) : undefined
if (!authorId) return { kind: 'url', state }
const authors = (state.authors ?? []).filter((entry) => entry !== authorId)
return {
kind: 'url',
state: withPageReset({ ...state, authors: authors.length ? authors : undefined }),
}
}
return { kind: 'url', state }
}
export const clearCampaignUpdatesFeedFilters = (): CampaignUpdatesFeedOmniboxAction => ({
kind: 'clear',
state: { page: 1 },
})

View file

@ -0,0 +1,164 @@
// @vitest-environment node
import { getPayload, type Payload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import config from '@/payload.config'
import {
loadCampaignUpdatesFeed,
loadCampaignUpdatesFeedFacets,
} from '@/utilities/municipality/campaignUpdatesFeedData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('campaign updates feed data (C89)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('feeds the actor across their whole portfolio with author and municipality identity', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const first = await campaignFixtures().getMunicipality()
const second = await campaignFixtures().getMunicipality()
await campaignFixtures().createMunicipalityUpdate({
municipality: first.id,
author: coordinator.id,
polarity: 'boa',
body: 'Festa da adesão',
})
await campaignFixtures().createMunicipalityUpdate({
municipality: second.id,
author: coordinator.id,
polarity: 'ruim',
urgent: true,
body: 'Perda de apoio',
})
// Scoped to this spec's municipalities: the coordinator's unrestricted
// read sees every update in the shared test DB, including rows other
// concurrently running spec files create — so global count assertions
// would race with them.
const result = await loadCampaignUpdatesFeed(payload, coordinator, {
page: 1,
slugs: [first.slug, second.slug],
})
expect(result.totalDocs).toBe(2)
expect(result.cards.some((card) => card.body === 'Festa da adesão')).toBe(true)
const urgentCard = result.cards.find((card) => card.body === 'Perda de apoio')
expect(urgentCard).toMatchObject({
polarity: 'ruim',
urgent: true,
author: { name: coordinator.name },
})
expect([first.slug, second.slug].includes(urgentCard!.municipality.slug)).toBe(true)
expect(urgentCard!.municipality.name.length).toBeGreaterThan(0)
})
it('scopes the feed and its facets to the advisor portfolio', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const advisor = await campaignFixtures().createCampaignUser('advisor')
const otherAdvisor = await campaignFixtures().createCampaignUser('advisor')
const assigned = await campaignFixtures().getMunicipality()
const other = await campaignFixtures().getMunicipality()
await campaignFixtures().assignMunicipalityAdvisors(assigned, [advisor])
await campaignFixtures().assignMunicipalityAdvisors(other, [otherAdvisor])
await campaignFixtures().createMunicipalityUpdate({
municipality: assigned.id,
author: coordinator.id,
polarity: 'neutra',
body: 'Visita no meu município',
})
await campaignFixtures().createMunicipalityUpdate({
municipality: other.id,
author: otherAdvisor.id,
polarity: 'neutra',
body: 'Atualização alheia',
})
const result = await loadCampaignUpdatesFeed(payload, advisor, { page: 1 })
expect(result.totalDocs).toBe(1)
expect(result.cards[0]?.body).toBe('Visita no meu município')
const facets = await loadCampaignUpdatesFeedFacets(payload, advisor)
expect(facets.municipalities.map((municipality) => municipality.slug)).toEqual([assigned.slug])
expect(facets.authorOptions.map((option) => option.label)).toContain(coordinator.name)
expect(facets.authorOptions.map((option) => option.label)).not.toContain(otherAdvisor.name)
})
it('gives the coordinator the full municipality catalog as facet options', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const facets = await loadCampaignUpdatesFeedFacets(payload, coordinator)
expect(facets.municipalities.map((municipality) => municipality.slug)).toEqual(
municipalityCatalog
.map((entry) => entry.slug)
.sort((left, right) => left.localeCompare(right, 'pt-BR')),
)
})
it('applies every feed filter to the query', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const otherAuthor = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
await campaignFixtures().createMunicipalityUpdate({
municipality: municipality.id,
author: coordinator.id,
polarity: 'boa',
urgent: true,
body: 'Adesão da comunidade',
})
await campaignFixtures().createMunicipalityUpdate({
municipality: municipality.id,
author: otherAuthor.id,
polarity: 'neutra',
body: 'Reunião de rotina',
})
// Every query below is scoped to this spec's municipality so counts can't
// race with concurrent spec files writing coordinator-visible updates.
const byText = await loadCampaignUpdatesFeed(payload, coordinator, {
page: 1,
slugs: [municipality.slug],
q: 'adesão',
})
expect(byText.cards.map((card) => card.body)).toEqual(['Adesão da comunidade'])
const byUrgent = await loadCampaignUpdatesFeed(payload, coordinator, {
page: 1,
slugs: [municipality.slug],
urgent: true,
})
expect(byUrgent.totalDocs).toBe(1)
const byAuthor = await loadCampaignUpdatesFeed(payload, coordinator, {
page: 1,
slugs: [municipality.slug],
authors: [otherAuthor.id],
})
expect(byAuthor.cards.map((card) => card.body)).toEqual(['Reunião de rotina'])
const byMunicipality = await loadCampaignUpdatesFeed(payload, coordinator, {
page: 1,
slugs: [municipality.slug],
})
expect(byMunicipality.totalDocs).toBe(2)
expect(byMunicipality.cards[0]?.municipality.slug).toBe(municipality.slug)
})
it('denies leaders the feed and its facets', async () => {
const leader = await campaignFixtures().createCampaignUser('leader')
await expect(loadCampaignUpdatesFeed(payload, leader, { page: 1 })).rejects.toThrow()
await expect(loadCampaignUpdatesFeedFacets(payload, leader)).rejects.toThrow()
})
})

View file

@ -0,0 +1,233 @@
import { describe, expect, it } from 'vitest'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import {
buildCampaignUpdatesFeedHref,
buildCampaignUpdatesFeedWhere,
parseCampaignUpdatesFeedParams,
resolveCampaignUpdatesFeedUrl,
serializeCanonicalCampaignUpdatesFeedSearchParams,
} from '@/utilities/municipality/municipalityUpdateListUrl'
import {
applyCampaignUpdatesFeedSuggestion,
buildCampaignUpdatesFeedChips,
buildCampaignUpdatesFeedSuggestions,
clearCampaignUpdatesFeedFilters,
removeCampaignUpdatesFeedChip,
} from '@/utilities/municipality/municipalityUpdateOmnibox'
const [firstSlug, secondSlug] = municipalityCatalog.map((entry) => entry.slug)
describe('campaign updates feed URL contract (C89)', () => {
it('empty params resolve to the default feed and the bare base path', () => {
const { state } = resolveCampaignUpdatesFeedUrl({})
expect(state).toEqual({ page: 1 })
expect(buildCampaignUpdatesFeedHref(state, 1)).toBe('/campanha/atualizacoes')
})
it('canonicalizes page 1 away and clamps an out-of-range page', () => {
const parsed = parseCampaignUpdatesFeedParams({ page: '1' })
expect(parsed.page).toBe(1)
expect(buildCampaignUpdatesFeedHref(parsed, 1)).toBe('/campanha/atualizacoes')
const resolved = resolveCampaignUpdatesFeedUrl({ page: '99' }, 3)
expect(resolved.state.page).toBe(3)
})
it('parses free-text q and trims empties away', () => {
const state = parseCampaignUpdatesFeedParams({ q: ' reunião ' })
expect(state.q).toBe('reunião')
expect(buildCampaignUpdatesFeedHref(state, 1)).toBe('/campanha/atualizacoes?q=reuni%C3%A3o')
expect(parseCampaignUpdatesFeedParams({ q: ' ' }).q).toBeUndefined()
})
it('parses municipality slugs, dropping unknown or invalid tokens', () => {
const state = parseCampaignUpdatesFeedParams({
slug: [firstSlug, secondSlug, 'nao-existe'],
})
expect(state.slugs).toEqual([firstSlug, secondSlug])
})
it('canonicalizes "all polarities" to absent while keeping a subset', () => {
const all = parseCampaignUpdatesFeedParams({
polarity: ['boa', 'neutra', 'ruim'],
})
expect(all.polarities).toBeUndefined()
const subset = parseCampaignUpdatesFeedParams({ polarity: ['boa', 'ruim'] })
expect(subset.polarities).toEqual(['boa', 'ruim'])
expect(serializeCanonicalCampaignUpdatesFeedSearchParams(subset).toString()).toBe(
'polarity=boa&polarity=ruim',
)
})
it('treats urgent as present-only boolean', () => {
expect(parseCampaignUpdatesFeedParams({ urgent: 'true' }).urgent).toBe(true)
expect(parseCampaignUpdatesFeedParams({ urgent: 'false' }).urgent).toBeUndefined()
expect(parseCampaignUpdatesFeedParams({}).urgent).toBeUndefined()
})
it('parses author ids numerically and sorted', () => {
const state = parseCampaignUpdatesFeedParams({ author: ['3', 'abc', '1'] })
expect(state.authors).toEqual([1, 3])
})
it('redirects when an unsupported search param is present', () => {
const resolved = resolveCampaignUpdatesFeedUrl({ q: 'x', foo: 'bar' })
expect(resolved.redirectHref).toBe('/campanha/atualizacoes?q=x')
})
it('does not redirect on the canonical contract', () => {
const resolved = resolveCampaignUpdatesFeedUrl({ q: 'x' })
expect(resolved.redirectHref).toBeUndefined()
})
})
describe('campaign updates feed where builder (C89)', () => {
it('builds an empty where for the default feed', () => {
expect(buildCampaignUpdatesFeedWhere({ page: 1 })).toEqual({})
})
it('maps every dimension to its Payload constraint', () => {
const where = buildCampaignUpdatesFeedWhere({
page: 1,
q: 'adesão',
slugs: [firstSlug],
polarities: ['boa'],
urgent: true,
authors: [3],
})
expect(where).toEqual({
and: [
{ body: { contains: 'adesão' } },
{ 'municipality.slug': { in: [firstSlug] } },
{ polarity: { in: ['boa'] } },
{ urgent: { equals: true } },
{ author: { in: [3] } },
],
})
})
it('filters municipality slugs through the relationship slug field, fail-closed', () => {
const where = buildCampaignUpdatesFeedWhere({
page: 1,
slugs: ['slug-sem-cadastro'],
})
expect(where).toEqual({
and: [{ 'municipality.slug': { in: ['slug-sem-cadastro'] } }],
})
})
})
describe('campaign updates feed omnibox (C89)', () => {
const base = parseCampaignUpdatesFeedParams({})
const authorOptions = [{ value: '3', label: 'Ana Assessora' }]
const municipalitySlugOptions = [firstSlug]
it('builds chips from URL filters', () => {
const state = parseCampaignUpdatesFeedParams({
q: 'feira',
slug: firstSlug,
polarity: 'boa',
urgent: 'true',
author: '3',
})
const chips = buildCampaignUpdatesFeedChips({
state,
municipalityNameBySlug: new Map([[firstSlug, 'Feira de Santana']]),
authorNameById: new Map([[3, 'Ana Assessora']]),
})
expect(chips.map((chip) => chip.id)).toEqual([
'q',
`slug:${firstSlug}`,
'polarity:boa',
'urgent',
'author:3',
])
expect(chips.find((chip) => chip.id === 'q')?.label).toBe('Busca: feira')
})
// Small dimension shortcuts (urgente/polaridade/autor) surface on focus; the
// big Município group is revealed by typing — same as the municipality list.
it('suggests the small dimension groups on an empty query', () => {
const suggestions = buildCampaignUpdatesFeedSuggestions({
query: '',
municipalitySlugOptions,
authorOptions,
})
const groups = new Set(suggestions.map((suggestion) => suggestion.group))
expect(groups).toEqual(new Set(['Urgente', 'Polaridade', 'Autor']))
})
it('reveals the Município group once text is typed', () => {
const suggestions = buildCampaignUpdatesFeedSuggestions({
query: firstSlug.slice(0, 4),
municipalitySlugOptions,
authorOptions,
})
expect(suggestions.some((entry) => entry.group === 'Município')).toBe(true)
})
it('matches typed text against a dimension and the text search', () => {
const suggestions = buildCampaignUpdatesFeedSuggestions({
query: 'pol',
municipalitySlugOptions,
authorOptions,
})
expect(suggestions.some((entry) => entry.id === 'q:pol')).toBe(true)
expect(suggestions.some((entry) => entry.group === 'Polaridade')).toBe(true)
})
it('applies toggles and text search, resetting the page', () => {
const text = applyCampaignUpdatesFeedSuggestion({
state: base,
suggestionId: 'q:Mucuri',
})
expect(text).toEqual({ kind: 'url', state: { page: 1, q: 'Mucuri' } })
const slug = applyCampaignUpdatesFeedSuggestion({
state: base,
suggestionId: `slug:${firstSlug}`,
})
expect(slug).toEqual({ kind: 'url', state: { page: 1, slugs: [firstSlug] } })
const urgentOn = applyCampaignUpdatesFeedSuggestion({ state: base, suggestionId: 'urgent' })
const urgentOff = applyCampaignUpdatesFeedSuggestion({
state: urgentOn.state,
suggestionId: 'urgent',
})
expect(urgentOn).toEqual({ kind: 'url', state: { page: 1, urgent: true } })
expect(urgentOff.state.urgent).toBeUndefined()
const author = applyCampaignUpdatesFeedSuggestion({ state: base, suggestionId: 'author:3' })
expect(author).toEqual({ kind: 'url', state: { page: 1, authors: [3] } })
})
it('rejects unknown suggestion ids and bad author ids', () => {
expect(applyCampaignUpdatesFeedSuggestion({ state: base, suggestionId: 'nope' }).state).toEqual(
base,
)
expect(
applyCampaignUpdatesFeedSuggestion({ state: base, suggestionId: 'author:abc' }).state,
).toEqual(base)
expect(
applyCampaignUpdatesFeedSuggestion({ state: base, suggestionId: 'polarity:beta' }).state,
).toEqual(base)
})
it('removes chips dimension by dimension and clears everything', () => {
const state = parseCampaignUpdatesFeedParams({
q: 'x',
slug: firstSlug,
polarity: 'boa',
urgent: 'true',
author: '3',
})
const afterQ = removeCampaignUpdatesFeedChip({ state, chipId: 'q' })
expect(afterQ.state.q).toBeUndefined()
const afterSlug = removeCampaignUpdatesFeedChip({ state, chipId: `slug:${firstSlug}` })
expect(afterSlug.state.slugs).toBeUndefined()
const clear = clearCampaignUpdatesFeedFilters()
expect(clear).toEqual({ kind: 'clear', state: { page: 1 } })
})
})