diff --git a/docs/plans/feed-atualizacoes-campanha-threads-impl.md b/docs/plans/feed-atualizacoes-campanha-threads-impl.md new file mode 100644 index 00000000..628688df --- /dev/null +++ b/docs/plans/feed-atualizacoes-campanha-threads-impl.md @@ -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 `
` dos filtros — o React 19 não + liga progressive enhancement (`$ACTION_ID`) num action-form aninhado em outro + `` 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 `
`, + `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) diff --git a/src/app/(campaign)/campanha/(app)/atualizacoes/createFormActions.ts b/src/app/(campaign)/campanha/(app)/atualizacoes/createFormActions.ts new file mode 100644 index 00000000..93dfdad6 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/atualizacoes/createFormActions.ts @@ -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 => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx b/src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx index 2713141d..5f9cb462 100644 --- a/src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx @@ -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> +} + +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 ( - -
-
-
-

Atualizações em breve

-

- O painel de atualizações da campanha ainda está em construção. Volte depois para conferir - as novidades. -

-
+ + + + + + buildCampaignUpdatesFeedHref(canonicalState, page)} + /> + + ) } diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts index b32d5971..2ce3890d 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts @@ -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 => 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.' } }, diff --git a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts index 8cddfde0..3a336fd2 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts @@ -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 => 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.' } }, diff --git a/src/components/campaign/municipality/CampaignUpdatesCreateModal.tsx b/src/components/campaign/municipality/CampaignUpdatesCreateModal.tsx new file mode 100644 index 00000000..d2704bd8 --- /dev/null +++ b/src/components/campaign/municipality/CampaignUpdatesCreateModal.tsx @@ -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 ``, 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 = ( + + + + Município + + O registro fica vinculado a um município da sua carteira. + + + {municipalityError ? ( + {municipalityError} + ) : null} + + + + + {state.status !== 'success' ? ( + + ) : null} + +
+ + +
+ + ) + + if (isMobile) { + return ( + + + Nova atualização + + Registre um novo fato de campo em um município + +
+ {createForm} +
+
+
+ ) + } + + return ( + + + Nova atualização + Registre um novo fato de campo em um município. + {createForm} + + + ) +} diff --git a/src/components/campaign/municipality/CampaignUpdatesFeed.tsx b/src/components/campaign/municipality/CampaignUpdatesFeed.tsx new file mode 100644 index 00000000..c724c4a2 --- /dev/null +++ b/src/components/campaign/municipality/CampaignUpdatesFeed.tsx @@ -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 }) => ( +
  • + +
    +

    {card.body ?? 'Sem texto.'}

    +
    + + {municipalityUpdatePolarityLabels[card.polarity]} + + {card.urgent ? Urgente : null} + {card.adversarySignal ? Adversário : null} +
    +

    + {card.author.name} + + {card.municipality.name} + + {dateTimeFormatter.format(new Date(card.createdAt))} +

    +
    +
  • +) + +export const CampaignUpdatesFeed = ({ cards }: { cards: CampaignUpdatesFeedCard[] }) => { + if (cards.length === 0) { + return ( + + ) + } + + return ( +
      + {cards.map((card) => ( + + ))} +
    + ) +} diff --git a/src/components/campaign/municipality/CampaignUpdatesFilters.tsx b/src/components/campaign/municipality/CampaignUpdatesFilters.tsx new file mode 100644 index 00000000..13a6e603 --- /dev/null +++ b/src/components/campaign/municipality/CampaignUpdatesFilters.tsx @@ -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 ( + <> +
    { + event.preventDefault() + }} + > + { + runAction(applyCampaignUpdatesFeedSuggestion({ state, suggestionId })) + }} + onCommitQuery={(text) => { + runAction(applyCampaignUpdatesFeedSuggestion({ state, suggestionId: `q:${text}` })) + }} + onRemoveChip={(chipId) => { + runAction(removeCampaignUpdatesFeedChip({ state, chipId })) + }} + onClearAll={() => { + runAction(clearCampaignUpdatesFeedFilters()) + }} + trailing={ + + } + /> + + + + ) +} diff --git a/src/components/campaign/municipality/MunicipalityUpdateFeed.tsx b/src/components/campaign/municipality/MunicipalityUpdateFeed.tsx index 8f70402a..5d35cd2e 100644 --- a/src/components/campaign/municipality/MunicipalityUpdateFeed.tsx +++ b/src/components/campaign/municipality/MunicipalityUpdateFeed.tsx @@ -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) => (
  • - + {municipalityUpdatePolarityLabels[update.polarity]} {update.urgent ? Urgente : null} diff --git a/src/lib/schemas/municipalityUpdate.ts b/src/lib/schemas/municipalityUpdate.ts index 3acd14b2..6bd61ee7 100644 --- a/src/lib/schemas/municipalityUpdate.ts +++ b/src/lib/schemas/municipalityUpdate.ts @@ -11,6 +11,16 @@ export const municipalityUpdatePolarityLabels: Record = { + 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.' diff --git a/src/utilities/loadNamesByIds.ts b/src/utilities/loadNamesByIds.ts index fc47c39d..5b7fd1f4 100644 --- a/src/utilities/loadNamesByIds.ts +++ b/src/utilities/loadNamesByIds.ts @@ -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, + ids: readonly number[], +): Promise> => { + 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, diff --git a/src/utilities/municipality/campaignUpdatesFeedData.ts b/src/utilities/municipality/campaignUpdatesFeedData.ts new file mode 100644 index 00000000..7ecfb1ef --- /dev/null +++ b/src/utilities/municipality/campaignUpdatesFeedData.ts @@ -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 => { + 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 })), + } +} diff --git a/src/utilities/municipality/municipalityUpdateFormData.ts b/src/utilities/municipality/municipalityUpdateFormData.ts new file mode 100644 index 00000000..e18c0e4b --- /dev/null +++ b/src/utilities/municipality/municipalityUpdateFormData.ts @@ -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'), + } +} diff --git a/src/utilities/municipality/municipalityUpdateListUrl.ts b/src/utilities/municipality/municipalityUpdateListUrl.ts new file mode 100644 index 00000000..b469ae21 --- /dev/null +++ b/src/utilities/municipality/municipalityUpdateListUrl.ts @@ -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(campaignUpdatesFeedParamNames) + +const municipalityUpdatePolaritySet = new Set(municipalityUpdatePolarities) + +const parseSlugsParam = (raw: string | string[] | undefined): string[] => { + const slugs: string[] = [] + const seen = new Set() + 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() + 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( + 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 } : {} +} diff --git a/src/utilities/municipality/municipalityUpdateOmnibox.ts b/src/utilities/municipality/municipalityUpdateOmnibox.ts new file mode 100644 index 00000000..5f6206f5 --- /dev/null +++ b/src/utilities/municipality/municipalityUpdateOmnibox.ts @@ -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 = (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 + /** Label overrides for a selected author id (server-provided name). */ + authorNameById: ReadonlyMap +}): 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[] => { + // 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[] = [ + 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, + 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 }, +}) diff --git a/tests/int/campaignUpdatesFeedData.int.spec.ts b/tests/int/campaignUpdatesFeedData.int.spec.ts new file mode 100644 index 00000000..660cc386 --- /dev/null +++ b/tests/int/campaignUpdatesFeedData.int.spec.ts @@ -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() + }) +}) diff --git a/tests/unit/municipalityUpdateList.unit.spec.ts b/tests/unit/municipalityUpdateList.unit.spec.ts new file mode 100644 index 00000000..9a7d50df --- /dev/null +++ b/tests/unit/municipalityUpdateList.unit.spec.ts @@ -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 } }) + }) +})