Merge branch 'cursor/70386841'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 03:54:15 -03:00
commit b16d2c9831
23 changed files with 761 additions and 172 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B27 entregue em código (2026-07-26):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — trocou a lista de checkboxes + botão "Salvar assessores" por chips removíveis (`Badge`+`XIcon`, `aria-label="Remover <nome>"`) acima de um `Command` (`shouldFilter={false}` + `matchesAtWordStart`, acento-insensível), com gravação **por delta** (um toggle por vez, não replace do array). Fase 1 — `nextAdvisorIdsAfterMembership` subiu de `actions/advisor.ts` para `src/lib/municipalityAdvisorMembership.ts` (compartilhado com a carteira do B19); `setMunicipalityAdvisorMembershipRecord`/`setMunicipalityAdvisorMembership` em `actions/municipality.ts` (mesma transação + lock `municipality-advisors:{id}` + `reloadUnrestrictedActor`, mas sem `assertTargetAdvisor` — o alvo pode ser coordenador/candidato, legal nesta tela). Fase 2 — endpoint `POST /campanha/municipios/advisors` espelhando `expected-votes/route.ts` (`isSameOriginRequest` local, zod, `mapCampaignFormActionError`). Fase 3 — `MunicipalityListAdvisorsControl.tsx` reescrito: estado otimista + contador de pendências (reconcilia só quando toda entrega em voo assentou, nunca aborta um delta anterior), `Alert` de erro, `aria-live` `sr-only`; polish do `/impeccable` alinhou o gatilho (`aria-expanded`/`aria-haspopup`/realce `bg-muted/60` ao abrir) ao padrão do `MunicipalityListExpectedVotesControl` irmão (B24). `assignMunicipalityAdvisorsRecord` (replace do array) sobrevive só para `/editar`. Achado de infraestrutura no caminho: o Playwright `webServer` (`pnpm dev`) recompila uma rota POST-only no primeiro hit e o Fast Refresh resultante aborta o fetch em voo do teste (`ERR_ABORTED`) — `tests/e2e/setup.e2e.spec.ts` ganhou um prewarm dummy de `/campanha/municipios/advisors` (e retroativamente de `expected-votes/`, mesmo padrão) antes dos specs reais. Gate verde: tsc/lint/format/`check:cycles` limpos, 393 unit+int (6 novos), e2e do fluxo (isolado e em suíte) verde — falhas observadas em rodadas full-suite eram flakiness de ambiente sob carga sustentada (testes não relacionados falhando de forma rotativa, sempre verdes isolados), não regressão do B27; build produção ok; Aikido 0 achados nos 9 arquivos novos/editados. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md).
- **B23 entregue em código (2026-07-26):** tooltip de conteúdo nas células das listas — a mecânica genérica (`cellTooltip` em `CampaignTableColumn`, `CampaignCellTooltip`, `CampaignHoverTooltip` promovido para `shared/`) já tinha saído de graça no **E10 ✓**; este item fechou o que faltava: `openOnTouch`/`disabled` em `CampaignHoverTooltip` (default `true`/`false`, sem mudança nos 4 call sites pré-B23) para conviver com um Popover no mesmo gatilho sem roubar o tap nem sobreviver atrás dele — e, no `/simplify`, ganhou um terceiro comportamento: `content` falsy renderiza o `children` sem wrapper, então os dois consumidores abaixo não precisam mais de um ternário `content ? <CampaignHoverTooltip>… : children` cada; `formatAdvisorNamesTooltip` + `advisorEntriesFromIds` (dedupe do id→nome entre a lista e o controle) junto de `MunicipalityAdvisorAvatarStack.tsx`; e os dois consumidores de `/campanha/municipios` — célula "Assessores" (não-coordenador via `cellTooltip`; coordenador envolvendo o próprio `PopoverTrigger`, `openOnTouch={false}`/`disabled={open}`, nunca os dois ao mesmo tempo) e célula "Tendência" (tooltip só quando há `politicalTrendNote`; em toque a nota aparece como leitura dentro do próprio Popover, acima do select). Verificado manualmente em `/campanha/municipios` como coordenador e assessor: hover mostra nomes/justificativa, foco de teclado abre a mesma tooltip, clique/tap abre o Popover sem tooltip órfã atrás. Gate verde (tsc/lint/format/knip — P3 pré-existente/check:cycles/443 unit/387 int/build); Aikido 0 findings. Plano: [`tooltip-celulas-listas.md`](../../../docs/plans/tooltip-celulas-listas.md).
- **B22 entregue em código (2026-07-26):** explicação por coluna no header de `/campanha/municipios` — `description?: ReactNode` em `CampaignTableHead` (`shared/CampaignTable.tsx`) e rename `tooltip`→`description` em `MunicipalitySortableHead`, ambos com o mesmo sublinhado pontilhado no label e o mesmo `CampaignHoverTooltip` compartilhado (já promovido para `shared/` pelo **E10 ✓**) — sem componente novo. Auditoria pré-implementação achou o escopo já reduzido: o primitivo de tooltip e o `cellTooltip` genérico já existiam (E10), então só faltava a prop em `CampaignTableHead` e o rename. Novo helper `campaignConceptOneLiner(id)` ao lado de `campaignConceptHref` em `campaignIntelligenceConcepts.ts` — fonte única para as colunas cobertas pelo **E18 ✓** (`expectedVotes` lê `oneLiner('meta')`, `goalCoverage` lê `oneLiner('cobertura-da-meta')` + a nota de cenário). Novo `MunicipalityListColumnId` + `municipalityColumnDescriptions` (`Record` de 11 entradas) em `municipalityLabels.ts` — a coluna "Classe" mantém o texto mais longo (`CLASS_COLUMN_DESCRIPTION`, movido de `MunicipalityList.tsx`) em vez do `oneLiner` terso de `classe-territorial`, decisão assumida porque uma classificação de 5 vias não cabe numa frase. `MunicipalityList.tsx` perdeu os três `*Hint` locais e a intersecção de tipo extra em `municipalityListColumns()`. Testes: pin do `campaignConceptOneLiner`, `describe('CampaignTableHead description')` (sem prop = render idêntico; com prop = 1 trigger, conteúdo fora do markup do servidor, mesmo contrato do `cellTooltip`), e contagem de `data-slot="tooltip-trigger"` no `<thead>` (10 staff / 5 leader) em `campaignComponents.unit.spec.ts`. Gate verde (tsc, lint, format, check:cycles, 442 unit / 387 int, build); `knip` segue com o erro pré-existente ao carregar `payload.config.ts` (ledgerado P3, confirmado idêntico fora desta mudança); Aikido sem achado. Fora do escopo (mantido do plano): copy nas outras 5 listas, tooltip nos cards mobile, variante Popover no touch. Plano: [`explicacao-colunas-header-listas.md`](../../../docs/plans/explicacao-colunas-header-listas.md).
- **C13 entregue em código (2026-07-25):** "Plano de Ação" → **"Atividade"** em toda a vertical. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades", hooks internos renomeados), campo `campaignDemand.actionPlan` → `activity`, 7 utilities `actionPlan*` → `activity*`, `access/actionPlans.ts` → `access/activities.ts`, `lib/schemas/actionPlan.ts` → `activity.ts`, `actions/actionPlan.ts` → `activity.ts`, `components/campaign/actionPlan/` → `activity/` (13 componentes), rota `(app)/planos/` → `(app)/atividades/` (criação em `atividades/nova`, feminino como `demandas/nova`) e sidebar "Atividades". **Migration data-preserving escrita à mão** `20260725_213000_rename_action_plan_to_activity`: 4 tabelas, 3 enums, 2 colunas, 39 índices (4 pkeys inclusos), 14 FKs, 2 sequences — lista canônica tirada de `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` porque o snapshot do drizzle não conhece o índice parcial `action_plan_upcoming_start_at_idx` (escrito à mão no C8) e porque o gerador do Payload emitiria DROP+CREATE. O `.json` foi derivado do snapshot anterior por transformação textual (o `migrate:create` é interativo para renames) e validado pelo teste que importa: `pnpm migrate:create __probe` responde "No schema changes detected". **Bônus aprovado pelo usuário nesta sessão:** os **8 índices fósseis `%plaza%`** da remodelagem de 23/07 (que renomeou as colunas `plaza_id` mas não os índices, deixando banco e snapshot em desacordo) foram alinhados na mesma migration; `down()` os restaura, para não inventar um estado que nunca existiu. Decisões de vocabulário: sidebar **"Atividades"** (uma palavra só, a da entidade) e rótulos de campo alinhados — **"Tipo de atividade"**, **"Origem da atividade"**, **"Resultado da atividade"** — porque "ação" ao lado de "atividade" reintroduzia a tradução mental que o item existe para matar; valores de enum `kind`/`status`/`origin` **inalterados**. **Guard:** o describe "banned campaign terminology" de `codebaseConventions.unit.spec.ts` virou tabela de termos varrendo `src`+`tests`+`scripts` (migrations fora — história congelada), banindo os compostos `actionPlan`, a copy "plano de ação" e a rota velha, mas **não** `plan`/`plano` isolado (colidiria com os planos de assinatura da Fase 2 white-label); allowlist: o próprio spec, `scripts/generate-remodel-municipalities-migration.mjs` e o comentário histórico em `campaignMigrationReconciliation.int.spec.ts`. Estendê-lo a `tests/` derrubou 8 fósseis "Praça" em fixtures, limpos no mesmo PR. Gate verde (tsc, lint, format, knip, check:cycles, 46 unit / 48 int / 7 e2e, build); Aikido sem achado. **Deploy:** o `pnpm build` da Vercel aplica a migration enquanto o deployment antigo ainda serve — deployar fora do horário de mesa (mesmo perfil da remodelagem de 23/07). **Pós-`/simplify`:** a varredura mecânica comeu uma interpolação — `tests/unit/recentVisits.unit.spec.ts` ficou com `label: \`Município \`` para todas as entradas, e passava porque o label não era asserido (restaurado, com assertion nova para fechar a brecha, e os slugs `praca-` do fixture alinhados). O guard ganhou tolerância a espaço (`action[\s_-]?plan`), que era o buraco por onde `access/shared.ts` mantinha "action plan" em prosa; com isso a allowlist encolheu — o comentário de `campaignMigrationReconciliation.int.spec.ts` passou a citar `activity` e o arquivo saiu da exceção (uma spec que cresce não fica fora do guard por causa de um comentário). Deliberadamente **não** ampliei o padrão de "Praça" para a forma sem cedilha: cobraria três entradas novas de allowlist (nomes reais de organização na planilha, o teste de entradas legadas `kind: 'nucleus'`) por um fóssil já corrigido. Também: três resíduos do find-and-replace (banner `// Action activities`, `DOSSIER_*_PLAN_LIMIT`, "a activity" em dois comentários), concordância em pt-BR ("Criada por", "atividades apoiadas" minúsculo), o `??` impossível em `ActivityForm`, e as duas buscas de FK da migration agora usam `to_regclass` em vez de `::regclass` (que lança em vez de retornar NULL, contradizendo a invariante do próprio cabeçalho). A entrada morta `action_plan_municipality_idx` saiu da lista de índices da migration (nenhuma migration jamais criou esse nome — só os snapshots o afirmam, e com `push: false` nenhum banco pode carregá-lo), deixando `up()` e `down()` com os mesmos 48 pares; e o detalhe de organização parou de derivar o rótulo de status à mão e passou a reusar `ActivityStatusBadge`, com `OrganizationDetailViewModel.activities[].status` tipado como `ActivityStatus` em vez de `string` (a origem que forçava o re-narrowing no consumidor). Plano: [`renomear-plano-acao-para-atividade.md`](../../../docs/plans/renomear-plano-acao-para-atividade.md).
@ -14,7 +15,6 @@ alwaysApply: false
- **B20 entregue (2026-07-25):** Municípios prioritários no Início — `pickDashboardPriorityMunicipalities` (`src/utilities/dashboardPriorityMunicipalities.ts`) ordena `priority=alta` por déficit E9/`central`, amostra 8; `CampaignDashboard` exibe Badge com `highPriorityCount`, CTA “Ver todas” → `?priority=alta`, chips com `list-none`. Unit `dashboardPriorityMunicipalities.unit.spec.ts`. Plano: [`municipios-prioritarios-inicio.md`](../../../docs/plans/municipios-prioritarios-inicio.md).
- **B21 entregue (2026-07-25):** nova rota staff `/campanha/territorios` com os 27 Territórios de Identidade no sistema de listas — sort/filtros/busca canônicos na URL, sub-linhas do Metropolitano achatadas sem perder a hierarquia, tabela mobile com primeira coluna sticky e drill para `/campanha/municipios?region=<TI>`. O chrome de header rico foi extraído para `shared/CampaignSortableHead` + `CampaignHeaderFilterPopover`, com os wrappers de municípios migrados sem mudança de contrato; B29/B33 agora só consomem essa peça. O painel de TI saiu do Início por decisão desta sessão. Sem migration/collection/action. Plano: [`pagina-territorios-identidade.md`](../../../docs/plans/pagina-territorios-identidade.md).
- **B28 registrado (2026-07-25):** e-mail + celular na lista `/campanha/liderancas` — colunas com copy-on-click + WhatsApp (`wa.me`) no padrão de `/campanha/assessores` (B19 ✓); `email` no view model da lista; extrair primitivo compartilhado (2º call site); **sem** toggle Editar in-list (Contact update adiado). Appetite ~0,5–0,75d; Janela 1; cortável; Impeccable B. Plano: [`email-celular-lista-liderancas.md`](../../../docs/plans/email-celular-lista-liderancas.md).
- **B27 registrado (2026-07-25):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (pedido de produto). Hoje: lista de checkboxes de todas as contas elegíveis + botão "Salvar assessores" (`MunicipalityListAdvisorsControl`, B9) → replace do array + `revalidateMunicipalityListPaths`. Plano: chips removíveis acima de um `Command` com busca acento-insensível; gravação **por delta** em endpoint JSON espelho do `expected-votes/` (sem revalidar 435 linhas), reusando o lock `municipality-advisors:{id}` e o cálculo puro do B19 (`nextAdvisorIdsAfterMembership` sobe para `src/lib/`) — mas **não** `setAdvisorMunicipalityMembership`, cujo `assertTargetAdvisor` recusa coordenador/candidato e o próprio ator (legais nesta tela). Gatilho da célula intacto (contrato de tooltip do B23); UI segue coordenador-only (candidato = adiado com gatilho, servidor já permite desde B19 ✓). Appetite ~0,75d; Janela 1–2; cortável; Impeccable B. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md).
- **Fill-in registrado (2026-07-25):** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); só lista + label de sort `deficit`; glossário/KPI strips intactos. Soft B16 ✓ (liberou o nome). Appetite ~0,25d; Janela 1; cortável; Impeccable B. Plano: [`renomear-coluna-cobertura-meta.md`](../../../docs/plans/renomear-coluna-cobertura-meta.md).
- **Fill-in registrado (2026-07-25):** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtrar ZE = filtrar Salvador (19 slugs). Remove coluna + filtro/sort UI + `?kind=` do contrato URL; schema/catálogo `kind` intacto (mapa, B8, dossiê). Appetite ~0,25–0,5d; Janela 1; cortável; Impeccable B. Plano: [`remover-coluna-tipo-municipios.md`](../../../docs/plans/remover-coluna-tipo-municipios.md).
- **Pass 2 — Consolidação de engenharia entregue (2026-07-25, W0–W5 + D3):** tudo que foi one-shotted desde o Pass 1 virou infraestrutura compartilhada e arquitetura documentada. **W1 sistema de listas:** colunas como dado em `shared/CampaignTable.tsx` (seams `mandatory`/`defaultVisible` deixam **B17 trivial**; serialização canônica congelada deixa **B18 trivial**), o god module `municipalityUi.ts` (765 linhas/59 exports) dividido em `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`, 8 superfícies migradas com URLs byte-idênticas (pins do W0 verdes) e **B16+ absorvido** (`useOptimistic` no popover, facet por slugs, hrefs via serializador canônico com pin de byte-igualdade, facets no mesmo `Promise.all`); fora por decisão D5: planos (cards), `TerritoryOverviewTable`, comparação, LeaderContacts, preview de import. **W2 fronteiras:** `slug`/`phone`/`wordStartFilter`/`voteEstimate` → `lib/`, chaves de consent em `lib/campaignConsentKeys.ts`, 21 loaders `server-only`, `lib/campaignRoles.ts` client-safe (sidebar sem o barrel), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em **15 subpastas de domínio**; decisão D1: **sem `src/domains/`**, sem ports-and-adapters (`utilities/` adiado com gatilho: 3º módulo novo de um domínio num mês). **W4a:** `electionInsights.ts` (941 linhas, 2 exports vivos) **deletado** — sobreviventes em `electionFormat.ts`/`voteTrend.ts`; **E10 nasce em módulo novo**. **W4b:** knip `exports`/`types`/`enumMembers` em **ERROR** no CI, 0 findings (kit ui sem peças mortas; `duplicates` off — únicos hits eram os 6 aliases deliberados `canUpdateX = canReadX`). **W4c:** tabs de detalhe extraídas + factory `detailTabUi`. **W4d:** `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções comentadas). **W4e:** tipos de fonte única (cenários, trend, strategy VM nomeado). **W4f:** copy Praça→Município varrida (~70 strings; apoiadores desambiguado: cidade do contato = "Cidade"). **D3:** `submitWhatsapp` resolve `Consent.key='whatsapp-inscricao'` fail-closed; migration `20260725_170000` tagueia o doc vivo — **revisar o SQL antes de push/deploy**. Artefatos novos: `docs/ARCHITECTURE.md` + `.cursor/rules/codebase-map.mdc` (atualizados no mesmo PR que mover coisas). Suites: 46 unit / 48 int / 7 e2e; gates verdes por entrega. Tracker: [`IMPROVE-CODE-QUALITY-PLAN.md`](../../../docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2.

View file

@ -1,7 +1,7 @@
# Combobox, chips e auto-save no popover de Assessores da lista de municípios
Status: rascunho
Atualizado em: 2026-07-25
Status: entregue
Atualizado em: 2026-07-26
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B27**)
Impeccable: B — encaixe no `MunicipalityListAdvisorsControl` (tabela desktop + card mobile de `/campanha/municipios`); sem rota nova de UI
Appetite: ~0,75 dia eng; 1 componente reescrito + 1 route handler JSON + 1 record de delta + 1 helper puro movido; sem migration
@ -122,8 +122,8 @@ Componentes:
## Referências
- `docs/roadmap.md` (Trilha B, B27; grafo; Janela 1–2; cortes seguros)
- `src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx` — superfície alvo (checkbox list + "Salvar assessores")
- `src/components/campaign/municipality/MunicipalityList.tsx` (~290–320 e card mobile ~483–494) — os dois pontos de uso do controle
- `src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx` — superfície entregue (chips + `Command` + auto-save por delta; antiga checkbox list + "Salvar assessores" removida)
- `src/components/campaign/municipality/MunicipalityList.tsx` (~378 e card mobile ~583) — os dois pontos de uso do controle (números de linha corrigidos nesta entrega)
- `src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx` — máquina de auto-save (pendência, rollback, live region) a espelhar
- `src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts` e `types.ts` — contrato do endpoint e `isSameOriginRequest`
- `src/components/campaign/advisor/AdvisorMunicipalityCell.tsx` — chips removíveis + gravação por delta (padrão visual e de interação)
@ -137,3 +137,11 @@ Componentes:
- `docs/plans/autosave-tendencia-lista-municipios.md` (**B24**) · `docs/plans/tooltip-celulas-listas.md` (**B23**) · `docs/plans/gerenciar-assessores.md` (**B19 ✓**) · `docs/plans/edicao-rapida-lista-pracas.md` (**B9 ✓**)
- AGENTS.md — Campaign auth, naming pt-BR/inglês, `overrideAccess: false`, escrita transacional
- `PRODUCT.md` / `DESIGN.md` — princípios 2, 3, 4 e 8; Field Desk · regras `campanha-edit-where-you-see.mdc`, `campanha-action-feedback.mdc`
## Entregue (2026-07-26)
Implementado como planejado, sem desvio de escopo. `nextAdvisorIdsAfterMembership` mora agora em `src/lib/municipalityAdvisorMembership.ts` (com unit tests próprios, que faltavam); `setMunicipalityAdvisorMembershipRecord`/`setMunicipalityAdvisorMembership` em `actions/municipality.ts` cobertos por 6 int tests (coordinator/candidate, auto-atribuição, negação de `advisor`/`leader`, idempotência, limite de 10); `POST /campanha/municipios/advisors` espelha `expected-votes/route.ts` byte a byte no contrato. O polish do `/impeccable` alinhou o gatilho ao irmão `MunicipalityListExpectedVotesControl` (`aria-expanded`/`aria-haspopup`/realce ao abrir) — única mudança visual além do que o plano já especificava. `assignMunicipalityAdvisorsFormAction` sobrevive intacto só para `/editar`; `pnpm exec knip` confirma nada órfão (a checagem full falha por um problema pré-existente P3 ao carregar `payload.config.ts`, não relacionado a este item).
Achado de infraestrutura fora do escopo original: o `webServer` do Playwright (`pnpm dev`) compila uma rota `POST`-only só no primeiro hit, e o Fast Refresh disparado por essa compilação recarrega a página e aborta o fetch em voo do e2e (`ERR_ABORTED`). Corrigido com um prewarm dummy de `/campanha/municipios/advisors` e `/campanha/municipios/expected-votes` em `tests/e2e/setup.e2e.spec.ts`, antes dos specs reais.
Gate: `tsc --noEmit`, `lint --max-warnings=0`, `format:check`, `check:cycles` limpos; 393 unit+int (6 novos) verdes; `pnpm build` verde; Aikido 0 achados nos 9 arquivos novos/editados. E2E do fluxo B27 verde tanto isolado quanto dentro da suíte completa — rodadas full-suite mostraram falhas rotativas em specs **não relacionados** (checkbox de consentimento, demandas, conceitos) sob carga sustentada da máquina, sempre verdes ao isolar; tratado como flakiness de ambiente pré-existente, não regressão desta entrega.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,56 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { setMunicipalityAdvisorMembership } from '@/app/(campaign)/campanha/actions/municipality'
import { MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES } from '@/lib/schemas/municipality'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import {
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import { uniqueRelationshipIds } from '@/utilities/relationship'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import type { MunicipalityListAdvisorsResponse } from './types'
export type { MunicipalityListAdvisorsResponse } from './types'
export const dynamic = 'force-dynamic'
const bodySchema = z.object({
municipalityId: positiveRelationshipId,
advisorId: positiveRelationshipId,
assigned: z.boolean(),
})
export async function POST(
request: Request,
): Promise<NextResponse<MunicipalityListAdvisorsResponse>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { municipalityId, advisorId, assigned } = bodySchema.parse(parsed.body)
const updated = await setMunicipalityAdvisorMembership({
municipality: municipalityId,
advisor: advisorId,
assigned,
})
return NextResponse.json({
status: 'success',
message: assigned ? 'Assessor atribuído.' : 'Assessor removido.',
advisors: uniqueRelationshipIds(updated.advisors),
})
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES,
genericMessage:
'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
})
}
}

View file

@ -0,0 +1,10 @@
export type MunicipalityListAdvisorsResponse =
| {
status: 'success'
message: string
advisors: number[]
}
| {
status: 'error'
message: string
}

View file

@ -6,9 +6,10 @@ import { setMunicipalityExpectedVotes } from '@/app/(campaign)/campanha/actions/
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import {
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
mapCampaignFormActionError,
} from '@/utilities/campaignFormActionError'
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import type { MunicipalityListExpectedVotesResponse } from './types'
@ -28,16 +29,6 @@ const bodySchema = z.object({
}),
})
const isSameOriginRequest = (request: Request): boolean => {
const origin = request.headers.get('origin')
if (!origin) return true
try {
return new URL(origin).origin === new URL(request.url).origin
} catch {
return false
}
}
export async function POST(
request: Request,
): Promise<NextResponse<MunicipalityListExpectedVotesResponse>> {
@ -45,18 +36,11 @@ export async function POST(
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
let json: unknown
try {
json = await request.json()
} catch {
return NextResponse.json(
{ status: 'error', message: 'Corpo da requisição inválido.' },
{ status: 400 },
)
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { municipalityId, expectedVotes } = bodySchema.parse(json)
const { municipalityId, expectedVotes } = bodySchema.parse(parsed.body)
const updated = await setMunicipalityExpectedVotes({
municipality: municipalityId,
expectedVotes,
@ -68,28 +52,10 @@ export async function POST(
savedExpectedVotes: toVoteEstimateScenarioViewModel(updated.expectedVotes),
})
} catch (error) {
const mapped = mapCampaignFormActionError({
error,
safeMessages: [
...municipalityStaffEditSafeMessages,
'Autenticação necessária.',
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
],
return campaignJsonMutationErrorResponse(error, {
safeMessages: municipalityStaffEditSafeMessages,
genericMessage:
'Não foi possível salvar os votos estimados. Verifique seu acesso e tente novamente.',
})
const isAuthError =
error instanceof Error &&
(error.message === 'Autenticação necessária.' ||
error.message === CAMPAIGN_SESSION_EXPIRED_MESSAGE)
return NextResponse.json(
{
status: 'error',
message: mapped.message ?? 'Não foi possível salvar os votos estimados.',
},
{ status: isAuthError ? 401 : 400 },
)
}
}

View file

@ -15,7 +15,10 @@ import {
requiredRelationshipFormValue,
voteEstimateScenarioFromForm,
} from '@/lib/formData'
import { parsePoliticalTrendStatusFormValue } from '@/lib/schemas/municipality'
import {
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
parsePoliticalTrendStatusFormValue,
} from '@/lib/schemas/municipality'
import { parseMunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import {
mapCampaignFormActionError,
@ -85,7 +88,7 @@ export const assignMunicipalityAdvisorsFormAction = async (
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente a coordenação geral ou o candidato designa assessores.'],
safeMessages: [MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE],
genericMessage:
'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
})

View file

@ -33,7 +33,6 @@ import {
import { loadMunicipalityListPageBundle } from '@/utilities/municipalityPageData'
import { getEligibleAdvisorOptions, loadAdvisorSummaries } from '@/utilities/municipalityViewModels'
import {
assignMunicipalityAdvisorsFormAction,
createMunicipalityListSignalFormAction,
setMunicipalityPoliticalTrendFormAction,
} from './municipalityStaffFormActions'
@ -131,7 +130,6 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
advisorOptions={advisorOptions}
columnFilterOptions={columnFilterOptions}
trendFormAction={setMunicipalityPoliticalTrendFormAction}
advisorsFormAction={assignMunicipalityAdvisorsFormAction}
signalFormAction={createMunicipalityListSignalFormAction}
state={state}
/>

View file

@ -5,6 +5,7 @@ import { randomBytes } from 'node:crypto'
import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership'
import {
ADVISOR_EMAIL_CONFLICT_MESSAGE,
ADVISOR_UNRESTRICTED_MESSAGE,
@ -21,7 +22,6 @@ import {
type AdvisorPasswordResetInput,
type AdvisorProfileUpdateInput,
} from '@/lib/schemas/advisor'
import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality'
import type { CampaignUser } from '@/payload-types'
import {
getCampaignActionContext,
@ -35,7 +35,7 @@ import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { revalidateMunicipalityListPaths } from '@/utilities/municipalityRevalidation'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { relationshipId } from '@/utilities/relationship'
import { uniqueRelationshipIds } from '@/utilities/relationship'
const revalidateAdvisorPaths = (advisorId?: number) => {
revalidatePath('/campanha/assessores', 'page')
@ -85,30 +85,6 @@ const translateUniqueEmailConflict = (error: unknown): never => {
throw error instanceof Error ? error : new Error(String(error))
}
/**
* Returns the next advisor-id list after applying one membership change, or
* `null` when the municipality is already in the desired state (no write
* needed). Shared by the single-toggle and batch (territory/ZE) mutations.
*/
const nextAdvisorIdsAfterMembership = (
currentAdvisorIDs: readonly number[],
advisorId: number,
assigned: boolean,
): number[] | null => {
const alreadyAssigned = currentAdvisorIDs.includes(advisorId)
if (assigned === alreadyAssigned) return null
if (assigned) {
if (currentAdvisorIDs.length >= MAX_ADVISORS_PER_MUNICIPALITY) {
throw new Error(
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
)
}
return [...currentAdvisorIDs, advisorId]
}
return currentAdvisorIDs.filter((id) => id !== advisorId)
}
export const createAdvisorRecord = async (
payload: Payload,
actor: CampaignUser,
@ -213,13 +189,7 @@ export const setAdvisorMunicipalityMembershipRecord = async (
req,
})
const currentAdvisorIDs = [
...new Set(
(municipality.advisors ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null),
),
]
const currentAdvisorIDs = uniqueRelationshipIds(municipality.advisors)
const nextAdvisorIDs = nextAdvisorIdsAfterMembership(currentAdvisorIDs, advisorId, assigned)
if (nextAdvisorIDs === null) return municipality
@ -289,13 +259,7 @@ export const setAdvisorMunicipalitiesBatchRecord = async (
req,
})
const currentAdvisorIDs = [
...new Set(
(municipality.advisors ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null),
),
]
const currentAdvisorIDs = uniqueRelationshipIds(municipality.advisors)
const nextAdvisorIDs = nextAdvisorIdsAfterMembership(currentAdvisorIDs, advisorId, assigned)
if (nextAdvisorIDs === null) continue

View file

@ -2,11 +2,15 @@
import type { Payload } from 'payload'
import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership'
import {
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
municipalityAdvisorMembershipSchema,
municipalityAdvisorsAssignmentSchema,
municipalityExpectedVotesSchema,
municipalityPoliticalTrendSchema,
municipalityStrategyUpdateSchema,
type MunicipalityAdvisorMembershipInput,
type MunicipalityAdvisorsAssignmentInput,
type MunicipalityExpectedVotesInput,
type MunicipalityPoliticalTrendInput,
@ -22,6 +26,7 @@ import {
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { uniqueRelationshipIds } from '@/utilities/relationship'
const getFreshStaffActor = (
payload: Payload,
@ -135,7 +140,7 @@ export const assignMunicipalityAdvisorsRecord = async (
await reloadUnrestrictedActor(
payload,
actor,
'Somente a coordenação geral ou o candidato designa assessores.',
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
req,
)
@ -160,3 +165,69 @@ export const assignMunicipalityAdvisors = async (input: MunicipalityAdvisorsAssi
const { payload, actor } = await getCampaignActionContext()
return assignMunicipalityAdvisorsRecord(payload, actor, input)
}
/**
* Single-advisor delta from the list popover — writes one toggle instead of
* replacing the whole `advisors` array, so a concurrent edit from another
* tab/actor is never clobbered. Same lock and eligibility hook as
* `assignMunicipalityAdvisorsRecord`; unlike it, the target can be coordinator
* or candidate too (the popover lists every unrestricted staff member as
* eligible, not just `advisor`-role accounts), so it does not reuse
* `assertTargetAdvisor` from `actions/advisor.ts`. Deliberately does not
* revalidate — the list's facet/sort by advisor reconciles on next navigation,
* same choice as the votos estimados popover.
*/
export const setMunicipalityAdvisorMembershipRecord = async (
payload: Payload,
actor: CampaignUser,
input: MunicipalityAdvisorMembershipInput,
) => {
const { municipality, advisor, assigned } = municipalityAdvisorMembershipSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
await reloadUnrestrictedActor(
payload,
actor,
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
req,
)
await acquireTextAdvisoryLocks(payload, req, [`municipality-advisors:${municipality}`])
const current = await payload.findByID({
collection: 'municipality',
id: municipality,
depth: 0,
select: { advisors: true },
overrideAccess: true,
req,
})
const currentAdvisorIDs = uniqueRelationshipIds(current.advisors)
const nextAdvisorIDs = nextAdvisorIdsAfterMembership(currentAdvisorIDs, advisor, assigned)
if (nextAdvisorIDs === null) return current
// Intentional admin bypass: unrestricted role was freshly verified above;
// the advisors field is admin-only by field access, and eligibility is
// reconfirmed by the `validateMunicipalityAdvisors` beforeValidate hook.
return payload.update({
collection: 'municipality',
id: municipality,
data: { advisors: nextAdvisorIDs },
depth: 0,
overrideAccess: true,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a atualização de assessores.' },
)
}
export const setMunicipalityAdvisorMembership = async (
input: MunicipalityAdvisorMembershipInput,
) => {
const { payload, actor } = await getCampaignActionContext()
return setMunicipalityAdvisorMembershipRecord(payload, actor, input)
}

View file

@ -24,7 +24,7 @@ import {
eligibleCampaignStaffWhere,
} from '@/utilities/campaignAccess'
import { politicalTrendLabels } from '@/utilities/municipalityLabels'
import { relationshipId } from '@/utilities/relationship'
import { relationshipId, uniqueRelationshipIds } from '@/utilities/relationship'
import {
voteEstimateScenarioGroupAccess,
voteEstimateScenarioGroupFields,
@ -81,9 +81,7 @@ const validateExpectedVotes: CollectionBeforeValidateHook = ({ data, originalDoc
const validateMunicipalityAdvisors: CollectionBeforeValidateHook = async ({ data, req }) => {
if (!data || data.advisors === undefined) return data
const advisorIDs = Array.isArray(data.advisors)
? [...new Set(data.advisors.map(relationshipId).filter((id): id is number => id !== null))]
: []
const advisorIDs = Array.isArray(data.advisors) ? uniqueRelationshipIds(data.advisors) : []
if (advisorIDs.length === 0) return data
const eligibleAdvisors = await req.payload.find({

View file

@ -83,7 +83,6 @@ export type MunicipalityListProps = {
advisorOptions: EligibleAdvisorOption[]
columnFilterOptions: MunicipalityColumnFilterOptions
trendFormAction: MunicipalityStaffFormAction
advisorsFormAction: MunicipalityStaffFormAction
signalFormAction: MunicipalityStaffFormAction
state: MunicipalityListState
}
@ -248,7 +247,6 @@ const municipalityListColumns = ({
advisorNamesById,
advisorOptions,
trendFormAction,
advisorsFormAction,
signalFormAction,
}: MunicipalityListProps): Array<CampaignTableColumn<MunicipalityListViewModel>> => [
{
@ -372,12 +370,10 @@ const municipalityListColumns = ({
isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
currentAdvisorIDs={municipality.advisorIDs}
isPriority={municipality.priority === 'alta'}
advisorNamesById={advisorNamesById}
options={advisorOptions}
formAction={advisorsFormAction}
/>
) : (
<MunicipalityAdvisorAvatarStack
@ -600,12 +596,10 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
{isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
currentAdvisorIDs={municipality.advisorIDs}
isPriority={isPriority}
advisorNamesById={advisorNamesById}
options={advisorOptions}
formAction={props.advisorsFormAction}
/>
) : names.length ? (
names.join(', ')

View file

@ -1,8 +1,9 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { XIcon } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { MunicipalityListAdvisorsResponse } from '@/app/(campaign)/campanha/(app)/municipios/advisors/types'
import {
advisorEntriesFromIds,
formatAdvisorNamesTooltip,
@ -10,58 +11,170 @@ import {
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/Badge'
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { cn } from '@/lib/utils'
import { matchesAtWordStart } from '@/lib/wordStartFilter'
import type {
EligibleAdvisorOption,
MunicipalityAdvisorSummary,
} from '@/utilities/municipalityViewModels'
const ADVISORS_ENDPOINT = '/campanha/municipios/advisors'
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar os assessores. Tente novamente.'
const sameIdSet = (left: readonly number[], right: readonly number[]): boolean => {
if (left.length !== right.length) return false
const rightSet = new Set(right)
return left.every((id) => rightSet.has(id))
}
type MunicipalityListAdvisorsControlProps = {
municipalityID: number
municipalitySlug: string
currentAdvisorIDs: number[]
/** Raises the empty state to "Sem responsável" — see `MissingAdvisorBadge`. */
isPriority: boolean
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
options: EligibleAdvisorOption[]
formAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
}
export const MunicipalityListAdvisorsControl = ({
municipalityID,
municipalitySlug,
currentAdvisorIDs,
isPriority,
advisorNamesById,
options,
formAction,
}: MunicipalityListAdvisorsControlProps) => {
const [open, setOpen] = useState(false)
const [state, submitAction, isPending] = useActionState(formAction, {})
const currentSet = new Set(currentAdvisorIDs)
const names = advisorEntriesFromIds(currentAdvisorIDs, advisorNamesById)
const tooltipContent = formatAdvisorNamesTooltip(names)
const [selectedIDs, setSelectedIDs] = useState<number[]>(currentAdvisorIDs)
const [query, setQuery] = useState('')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isPending, setIsPending] = useState(false)
const pendingCountRef = useRef(0)
const lastPropsIDsRef = useRef(currentAdvisorIDs)
// Requests can settle out of send order (network jitter), so the send order
// is tracked explicitly: only the response with the highest sequence number
// seen so far is kept, and it is adopted — win or fail — once every
// in-flight delta has settled (`pendingCountRef` back to 0). No response
// ever writes `lastPropsIDsRef`: that ref exists solely to tell a genuine
// external prop change (nav/refresh) apart from a no-op re-render carrying
// the same pre-edit content, and advancing it here would make a later
// stale re-render of that same pre-edit content look "external" and
// clobber the optimistic state back to it.
const requestSeqRef = useRef(0)
const latestConfirmedRef = useRef<{ seq: number; advisors: number[] } | null>(null)
// Adopt server props only when they change from outside (navigation / RSC
// refresh) — an in-flight delta's optimistic state must not be clobbered by
// a prop identical in content to what we already had.
useEffect(() => {
if (state.status !== 'success') return
toast.success(state.message)
setOpen(false)
}, [state.message, state.status])
if (sameIdSet(currentAdvisorIDs, lastPropsIDsRef.current)) return
lastPropsIDsRef.current = currentAdvisorIDs
setSelectedIDs(currentAdvisorIDs)
}, [currentAdvisorIDs])
// Every eligible account (coordinator/advisor/candidate) is listed here
// regardless of current assignment, so it doubles as the name lookup for an
// id the popover just optimistically added — no round trip needed to label
// it. `advisorNamesById` is the fallback for an id that fell out of
// eligibility but is still assigned; `advisorEntriesFromIds` drops the rare
// id found in neither, same as every other advisor cell in the list.
const advisorLookup = useMemo(() => {
const lookup = new Map<number, { id: number; name: string }>(advisorNamesById)
for (const option of options) lookup.set(option.id, { id: option.id, name: option.name })
return lookup
}, [advisorNamesById, options])
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs])
const chips = useMemo(
() =>
[...advisorEntriesFromIds(selectedIDs, advisorLookup)].sort((left, right) =>
left.name.localeCompare(right.name, 'pt-BR'),
),
[selectedIDs, advisorLookup],
)
// B23's hover/focus tooltip stays on the trigger even though it repeats the
// chips: it's the no-open-popover read. `null` for an empty list — the
// trigger already reads "Sem responsável"/"Sem assessor" by extenso.
const tooltipContent = formatAdvisorNamesTooltip(chips)
const filteredOptions = useMemo(
() => options.filter((option) => matchesAtWordStart(option.name, query)),
[options, query],
)
const toggle = (advisorId: number, assigned: boolean) => {
setErrorMessage(null)
setSelectedIDs((current) => {
const next = new Set(current)
if (assigned) next.add(advisorId)
else next.delete(advisorId)
return [...next]
})
setQuery('')
pendingCountRef.current += 1
setIsPending(true)
const requestSeq = (requestSeqRef.current += 1)
void (async () => {
const revertDelta = () => {
setSelectedIDs((current) => {
const next = new Set(current)
if (assigned) next.delete(advisorId)
else next.add(advisorId)
return [...next]
})
}
// Single settle point for every exit path (success, mapped error,
// network failure): decrements the pending count and, once it reaches
// 0, stops the spinner and reconciles to the latest confirmed server
// set — even on this request's own failure, an earlier delta in the
// same batch may have already confirmed one.
const finishRequest = () => {
pendingCountRef.current = Math.max(0, pendingCountRef.current - 1)
if (pendingCountRef.current > 0) return
setIsPending(false)
if (latestConfirmedRef.current) setSelectedIDs(latestConfirmedRef.current.advisors)
}
try {
const response = await fetch(ADVISORS_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ municipalityId: municipalityID, advisorId, assigned }),
})
const payload = (await response.json()) as MunicipalityListAdvisorsResponse
if (!response.ok || payload.status !== 'success') {
revertDelta()
setErrorMessage(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
finishRequest()
return
}
if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) {
latestConfirmedRef.current = { seq: requestSeq, advisors: payload.advisors }
}
finishRequest()
} catch {
revertDelta()
setErrorMessage(SAVE_ERROR_MESSAGE)
finishRequest()
}
})()
}
const statusMessage = errorMessage ? errorMessage : isPending ? 'Salvando assessores.' : ''
return (
<Popover open={open} onOpenChange={setOpen}>
{/* No advisors: the button already reads "Sem responsável"/"Sem assessor"
by extenso, so a tooltip would only echo it (`tooltipContent` is
null — see `CampaignHoverTooltip` for the no-op contract). */}
<CampaignHoverTooltip
content={tooltipContent}
align="start"
@ -71,51 +184,93 @@ export const MunicipalityListAdvisorsControl = ({
<PopoverTrigger asChild>
<button
type="button"
className="min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-expanded={open}
aria-haspopup="dialog"
className={cn(
'min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
open ? 'bg-muted/60' : undefined,
)}
aria-label="Editar assessores"
>
<MunicipalityAdvisorAvatarStack advisors={names} isPriority={isPriority} />
<MunicipalityAdvisorAvatarStack advisors={chips} isPriority={isPriority} />
</button>
</PopoverTrigger>
</CampaignHoverTooltip>
<PopoverContent align="start" className="w-80">
<form action={submitAction} className="flex flex-col gap-3">
<input type="hidden" name="municipalityId" value={municipalityID} />
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
<p className="text-sm text-muted-foreground">
<PopoverContent align="start" className="w-80 p-0">
<div className="relative flex flex-col gap-2 p-3 pb-0">
<p className="text-sm font-medium">Atribuir assessores</p>
<p className="text-xs text-muted-foreground">
O assessor vê e gerencia somente os municípios que administra.
</p>
<ul className="flex max-h-60 flex-col gap-1 overflow-y-auto">
{options.map((option) => (
<li key={option.id}>
<Label
htmlFor={`municipality-list-advisor-${municipalityID}-${option.id}`}
className="flex min-h-11 items-center gap-3 rounded-md px-2 hover:bg-accent"
>
<Checkbox
id={`municipality-list-advisor-${municipalityID}-${option.id}`}
name="advisors"
value={String(option.id)}
defaultChecked={currentSet.has(option.id)}
/>
<span>
{option.name}
{option.isCurrent ? ' (você)' : ''}
</span>
</Label>
</li>
))}
</ul>
{state.message && state.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
{isPending ? (
<Spinner
className="absolute top-3 right-3 size-3.5 text-muted-foreground"
aria-label="Salvando assessores"
/>
) : null}
<Button type="submit" disabled={isPending} className="min-h-11 w-full">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar assessores
</Button>
</form>
</div>
{chips.length > 0 ? (
<div className="flex flex-wrap gap-1.5 px-3 pt-2">
{chips.map((chip) => (
<button
key={chip.id}
type="button"
aria-label={`Remover ${chip.name}`}
onClick={() => toggle(chip.id, false)}
>
<Badge
variant="secondary"
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
>
<span className="truncate">{chip.name}</span>
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
</Badge>
</button>
))}
</div>
) : null}
<Command shouldFilter={false} className="mt-1">
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Buscar assessor…"
aria-label="Buscar assessor"
/>
<CommandList>
{filteredOptions.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Nenhum resultado.
</p>
) : (
filteredOptions.map((option) => {
const assigned = selectedSet.has(option.id)
return (
<CommandItem
key={option.id}
value={`advisor-${option.id}`}
data-checked={assigned}
onSelect={() => toggle(option.id, !assigned)}
>
<span className="truncate">
{option.name}
{option.isCurrent ? ' (você)' : ''}
</span>
</CommandItem>
)
})
)}
</CommandList>
</Command>
{errorMessage ? (
<div className="p-3 pt-2">
<Alert variant="destructive">
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
</div>
) : null}
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</PopoverContent>
</Popover>
)

View file

@ -0,0 +1,27 @@
import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality'
/**
* Returns the next advisor-id list after applying one membership change, or
* `null` when the municipality is already in the desired state (no write
* needed). Shared by the advisor-carteira mutations (`actions/advisor.ts`)
* and the município-side quick edit (`actions/municipality.ts`, B27) — the
* two sides of the same `municipality.advisors` relation.
*/
export const nextAdvisorIdsAfterMembership = (
currentAdvisorIDs: readonly number[],
advisorId: number,
assigned: boolean,
): number[] | null => {
const alreadyAssigned = currentAdvisorIDs.includes(advisorId)
if (assigned === alreadyAssigned) return null
if (assigned) {
if (currentAdvisorIDs.length >= MAX_ADVISORS_PER_MUNICIPALITY) {
throw new Error(
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
)
}
return [...currentAdvisorIDs, advisorId]
}
return currentAdvisorIDs.filter((id) => id !== advisorId)
}

View file

@ -47,6 +47,21 @@ export const municipalityAdvisorsAssignmentSchema = z.object({
.transform((ids) => [...new Set(ids)]),
})
/** Single-toggle delta from the list popover — one advisor at a time, never the whole array. */
export const municipalityAdvisorMembershipSchema = z.object({
municipality: positiveRelationshipId,
advisor: positiveRelationshipId,
assigned: z.boolean(),
})
export const MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE =
'Somente a coordenação geral ou o candidato designa assessores.'
export const MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES = [
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
] as const
export const municipalityExpectedVotesSchema = z.object({
municipality: positiveRelationshipId,
expectedVotes: voteEstimateScenarioFieldsSchema,
@ -57,4 +72,5 @@ export type MunicipalityPoliticalTrendInput = z.input<typeof municipalityPolitic
export type MunicipalityAdvisorsAssignmentInput = z.input<
typeof municipalityAdvisorsAssignmentSchema
>
export type MunicipalityAdvisorMembershipInput = z.input<typeof municipalityAdvisorMembershipSchema>
export type MunicipalityExpectedVotesInput = z.input<typeof municipalityExpectedVotesSchema>

View file

@ -0,0 +1,57 @@
import { NextResponse } from 'next/server'
import {
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
mapCampaignFormActionError,
} from '@/utilities/campaignFormActionError'
export type CampaignJsonErrorBody = { status: 'error'; message: string }
/**
* Shared shell for `POST /campanha/**` JSON mutation routes (list-popover
* auto-save): parses the body, mapping a malformed one to the same 400 both
* routes already used. Domain parsing/success payloads stay per-route.
*/
export const parseCampaignJsonRequestBody = async (
request: Request,
): Promise<
{ ok: true; body: unknown } | { ok: false; response: NextResponse<CampaignJsonErrorBody> }
> => {
try {
return { ok: true, body: await request.json() }
} catch {
return {
ok: false,
response: NextResponse.json(
{ status: 'error', message: 'Corpo da requisição inválido.' },
{ status: 400 },
),
}
}
}
/**
* Maps a thrown error to the JSON error response: safe messages (incl. the
* two auth ones, appended once here) pass through as 400, an expired/missing
* session is 401, anything else collapses to `genericMessage`.
*/
export const campaignJsonMutationErrorResponse = (
error: unknown,
{ safeMessages, genericMessage }: { safeMessages: readonly string[]; genericMessage: string },
): NextResponse<CampaignJsonErrorBody> => {
const mapped = mapCampaignFormActionError({
error,
safeMessages: [...safeMessages, 'Autenticação necessária.', CAMPAIGN_SESSION_EXPIRED_MESSAGE],
genericMessage,
})
const isAuthError =
error instanceof Error &&
(error.message === 'Autenticação necessária.' ||
error.message === CAMPAIGN_SESSION_EXPIRED_MESSAGE)
return NextResponse.json(
{ status: 'error', message: mapped.message ?? genericMessage },
{ status: isAuthError ? 401 : 400 },
)
}

View file

@ -21,3 +21,8 @@ export const requireRelationshipId = (
if (id === null) throw new Error(message)
return id
}
/** Normalizes a `hasMany` relationship field (ids or populated docs, any depth) into a deduped id list. */
export const uniqueRelationshipIds = (values: readonly unknown[] | null | undefined): number[] => [
...new Set((values ?? []).map(relationshipId).filter((id): id is number => id !== null)),
]

View file

@ -0,0 +1,15 @@
/**
* CSRF guard for cookie-authenticated JSON route handlers (auto-save popovers):
* a same-origin browser request either omits `Origin` (same-site navigation)
* or sends one matching the request URL; anything else is rejected before the
* body is even parsed. Shared by every `POST /campanha/**` JSON endpoint.
*/
export const isSameOriginRequest = (request: Request): boolean => {
const origin = request.headers.get('origin')
if (!origin) return true
try {
return new URL(origin).origin === new URL(request.url).origin
} catch {
return false
}
}

View file

@ -122,6 +122,70 @@ test.describe('Municípios — jornadas por papel', () => {
await expect(page.getByText(`Assessoria: ${advisor.name}`)).toBeVisible()
})
test('coordinator assigns an advisor from the list combobox with auto-save (B27)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const coordinator = await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenador Combobox'),
email: `${fixtures.value('coordinator-combobox')}@example.com`,
password,
role: 'coordinator',
},
depth: 0,
})
const advisor = await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Assessora Combobox'),
email: `${fixtures.value('advisor-combobox')}@example.com`,
password,
role: 'advisor',
},
depth: 0,
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(page.getByRole('heading', { name: 'Municípios', exact: true })).toBeVisible()
const advisorsTrigger = page.getByRole('button', { name: 'Editar assessores' })
await advisorsTrigger.click()
const advisorsPopover = page.locator('[data-slot="popover-content"]')
await expect(advisorsPopover).toBeVisible()
const searchNamePart = advisor.name.split(' ')[0]
await advisorsPopover.getByRole('combobox', { name: 'Buscar assessor' }).fill(searchNamePart)
await advisorsPopover.getByText(advisor.name, { exact: false }).click()
// No "Salvar" button in this popover: selecting the option auto-saves the
// delta and renders it as a removable chip immediately.
const chip = advisorsPopover.getByRole('button', { name: `Remover ${advisor.name}` })
await expect(chip).toBeVisible()
// The popover stays open after the write (auto-save, not submit+close).
await expect(advisorsPopover).toBeVisible()
await page.keyboard.press('Escape')
// Persistence: reload and reopen to confirm the server, not just local
// state, now holds the assignment.
await page.reload()
await page.getByRole('button', { name: 'Editar assessores' }).click()
await expect(
page.locator('[data-slot="popover-content"]').getByRole('button', {
name: `Remover ${advisor.name}`,
}),
).toBeVisible()
})
test('coordinator registers a typed signal from the list freshness cell', async ({
campaign,
page,

View file

@ -17,4 +17,13 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
const response = await request.get(`${baseURL}${path}`)
expect(response.ok(), `Failed to prewarm ${path}`).toBe(true)
}
// POST-only API route handlers (auto-save popovers): Next dev compiles a
// route on its first hit, and that compile can trigger a full-page reload
// for any client currently connected — which aborts an in-flight fetch mid
// test. An unauthenticated POST never succeeds, but it still forces the
// compile before any spec's client makes the real request.
for (const path of ['/campanha/municipios/advisors', '/campanha/municipios/expected-votes']) {
await request.post(`${baseURL}${path}`, { data: {} }).catch(() => undefined)
}
})

View file

@ -0,0 +1,151 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { setMunicipalityAdvisorMembershipRecord } from '@/app/(campaign)/campanha/actions/municipality'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('setMunicipalityAdvisorMembershipRecord (B27)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it.each(['coordinator', 'candidate'] as const)(
'lets a %s assign and remove an advisor by delta',
async (role) => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser(role)
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
const assigned = await setMunicipalityAdvisorMembershipRecord(payload, actor, {
municipality: municipality.id,
advisor: advisor.id,
assigned: true,
})
fixtures.touchMunicipality(municipality.id)
expect(
assigned.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
).toContain(advisor.id)
const removed = await setMunicipalityAdvisorMembershipRecord(payload, actor, {
municipality: municipality.id,
advisor: advisor.id,
assigned: false,
})
expect(
removed.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)) ?? [],
).not.toContain(advisor.id)
},
)
it('lets a coordinator assign themself or the candidate — the path assertTargetAdvisor would block', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const candidate = await fixtures.createCampaignUser('candidate')
const municipality = await fixtures.getMunicipality()
const selfAssigned = await setMunicipalityAdvisorMembershipRecord(payload, coordinator, {
municipality: municipality.id,
advisor: coordinator.id,
assigned: true,
})
fixtures.touchMunicipality(municipality.id)
expect(
selfAssigned.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
).toContain(coordinator.id)
const candidateAssigned = await setMunicipalityAdvisorMembershipRecord(payload, coordinator, {
municipality: municipality.id,
advisor: candidate.id,
assigned: true,
})
expect(
candidateAssigned.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
).toContain(candidate.id)
})
it('denies advisor and leader actors', async () => {
const fixtures = campaignFixtures()
const advisorActor = await fixtures.createCampaignUser('advisor')
const leaderActor = await fixtures.createCampaignUser('leader')
const target = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await expect(
setMunicipalityAdvisorMembershipRecord(payload, advisorActor, {
municipality: municipality.id,
advisor: target.id,
assigned: true,
}),
).rejects.toThrow(/coordenação geral ou o candidato/i)
await expect(
setMunicipalityAdvisorMembershipRecord(payload, leaderActor, {
municipality: municipality.id,
advisor: target.id,
assigned: true,
}),
).rejects.toThrow(/coordenação geral ou o candidato/i)
})
it('is idempotent (no-op returns the same document without re-writing)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await setMunicipalityAdvisorMembershipRecord(payload, coordinator, {
municipality: municipality.id,
advisor: advisor.id,
assigned: true,
})
fixtures.touchMunicipality(municipality.id)
const again = await setMunicipalityAdvisorMembershipRecord(payload, coordinator, {
municipality: municipality.id,
advisor: advisor.id,
assigned: true,
})
expect(again.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id))).toEqual([
advisor.id,
])
})
it('rejects once the municipality is at the 10-advisor cap', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
for (let index = 0; index < 10; index += 1) {
const advisor = await fixtures.createCampaignUser('advisor')
await setMunicipalityAdvisorMembershipRecord(payload, coordinator, {
municipality: municipality.id,
advisor: advisor.id,
assigned: true,
})
}
fixtures.touchMunicipality(municipality.id)
const eleventh = await fixtures.createCampaignUser('advisor')
await expect(
setMunicipalityAdvisorMembershipRecord(payload, coordinator, {
municipality: municipality.id,
advisor: eleventh.id,
assigned: true,
}),
).rejects.toThrow(/no máximo 10 assessores/i)
})
})

View file

@ -567,12 +567,10 @@ describe('campaign visual foundation', () => {
const withAdvisors = renderToStaticMarkup(
createElement(MunicipalityListAdvisorsControl, {
municipalityID: 1,
municipalitySlug: 'seabra',
currentAdvisorIDs: [7],
isPriority: false,
advisorNamesById: new Map([[7, { id: 7, name: 'Ana Bastos', phone: null }]]),
options: [],
formAction: noopListFormAction,
}),
)
expect(withAdvisors.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1)
@ -585,12 +583,10 @@ describe('campaign visual foundation', () => {
const withoutAdvisors = renderToStaticMarkup(
createElement(MunicipalityListAdvisorsControl, {
municipalityID: 1,
municipalitySlug: 'seabra',
currentAdvisorIDs: [],
isPriority: false,
advisorNamesById: new Map(),
options: [],
formAction: noopListFormAction,
}),
)
expect(withoutAdvisors).not.toContain('data-slot="tooltip-trigger"')

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership'
import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality'
describe('nextAdvisorIdsAfterMembership', () => {
it('adds an advisor not yet present', () => {
expect(nextAdvisorIdsAfterMembership([1, 2], 3, true)).toEqual([1, 2, 3])
})
it('removes an advisor present in the list', () => {
expect(nextAdvisorIdsAfterMembership([1, 2, 3], 2, false)).toEqual([1, 3])
})
it('is a no-op (returns null) when already in the desired state', () => {
expect(nextAdvisorIdsAfterMembership([1, 2], 2, true)).toBeNull()
expect(nextAdvisorIdsAfterMembership([1, 2], 3, false)).toBeNull()
})
it('throws once the municipality is at the advisor cap', () => {
const atCap = Array.from({ length: MAX_ADVISORS_PER_MUNICIPALITY }, (_, index) => index + 1)
expect(() => nextAdvisorIdsAfterMembership(atCap, 999, true)).toThrow(
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
)
})
})