mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
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:
commit
e3f6d3f5eb
17 changed files with 1711 additions and 85 deletions
169
docs/plans/feed-atualizacoes-campanha-threads-impl.md
Normal file
169
docs/plans/feed-atualizacoes-campanha-threads-impl.md
Normal 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)
|
||||
|
|
@ -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.',
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.' }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.' }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
63
src/components/campaign/municipality/CampaignUpdatesFeed.tsx
Normal file
63
src/components/campaign/municipality/CampaignUpdatesFeed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
147
src/components/campaign/municipality/CampaignUpdatesFilters.tsx
Normal file
147
src/components/campaign/municipality/CampaignUpdatesFilters.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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'>,
|
||||
|
|
|
|||
161
src/utilities/municipality/campaignUpdatesFeedData.ts
Normal file
161
src/utilities/municipality/campaignUpdatesFeedData.ts
Normal 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 })),
|
||||
}
|
||||
}
|
||||
45
src/utilities/municipality/municipalityUpdateFormData.ts
Normal file
45
src/utilities/municipality/municipalityUpdateFormData.ts
Normal 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'),
|
||||
}
|
||||
}
|
||||
182
src/utilities/municipality/municipalityUpdateListUrl.ts
Normal file
182
src/utilities/municipality/municipalityUpdateListUrl.ts
Normal 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 } : {}
|
||||
}
|
||||
263
src/utilities/municipality/municipalityUpdateOmnibox.ts
Normal file
263
src/utilities/municipality/municipalityUpdateOmnibox.ts
Normal 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 },
|
||||
})
|
||||
164
tests/int/campaignUpdatesFeedData.int.spec.ts
Normal file
164
tests/int/campaignUpdatesFeedData.int.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
233
tests/unit/municipalityUpdateList.unit.spec.ts
Normal file
233
tests/unit/municipalityUpdateList.unit.spec.ts
Normal 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 } })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue