From 7c50e29aa7269ac4fd745d4f4d1c1375f122a399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 03:28:09 -0300 Subject: [PATCH 1/3] =?UTF-8?q?B27:=20combobox=20+=20chips=20+=20auto-save?= =?UTF-8?q?=20no=20popover=20de=20Assessores=20da=20lista=20de=20munic?= =?UTF-8?q?=C3=ADpios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the checkbox-list + "Salvar assessores" button in the Assessors popover (/campanha/municipios) with removable chips + a Command combobox, delta-based auto-save (one advisor add/remove per request, never the whole array), and optimistic UI with request sequencing to resolve out-of-order responses correctly. - New POST /campanha/municipios/advisors JSON route (mirrors expected-votes/), backed by setMunicipalityAdvisorMembership(Record) in actions/municipality.ts (text advisory lock + transaction). - nextAdvisorIdsAfterMembership moved to src/lib/municipalityAdvisorMembership.ts with unit tests. - Shared isSameOriginRequest (src/utilities/sameOriginRequest.ts) and uniqueRelationshipIds (src/utilities/relationship.ts) helpers, de-duplicating the same-origin CSRF check and the Set+map(relationshipId)+filter pattern across advisor call sites. - MunicipalityListAdvisorsControl.tsx rewritten: chips, Command combobox, per-item pending affordance, rollback on error, and a request-sequence guard so only the most recently sent delta's response is adopted when concurrent optimistic updates settle. - e2e coverage for the combobox auto-save flow; setup prewarms the new POST-only route to avoid Playwright dev-server HMR aborting the first request. - Plan and roadmap marked as delivered (B27). --- .cursor/rules/projects/nucleos-eleitorais.mdc | 2 +- .../combobox-assessores-lista-municipios.md | 16 +- docs/roadmap.md | 6 +- .../(app)/municipios/advisors/route.ts | 81 ++++++ .../(app)/municipios/advisors/types.ts | 10 + .../(app)/municipios/expected-votes/route.ts | 11 +- .../municipalityStaffFormActions.ts | 7 +- .../campanha/(app)/municipios/page.tsx | 2 - .../(campaign)/campanha/actions/advisor.ts | 44 +-- .../campanha/actions/municipality.ts | 73 ++++- .../municipality/MunicipalityList.tsx | 6 - .../MunicipalityListAdvisorsControl.tsx | 274 ++++++++++++++---- src/lib/municipalityAdvisorMembership.ts | 27 ++ src/lib/schemas/municipality.ts | 16 + src/utilities/relationship.ts | 5 + src/utilities/sameOriginRequest.ts | 15 + tests/e2e/campaignMunicipalities.e2e.spec.ts | 64 ++++ tests/e2e/setup.e2e.spec.ts | 9 + ...nMunicipalityAdvisorMembership.int.spec.ts | 151 ++++++++++ ...municipalityAdvisorMembership.unit.spec.ts | 26 ++ 20 files changed, 713 insertions(+), 132 deletions(-) create mode 100644 src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/municipios/advisors/types.ts create mode 100644 src/lib/municipalityAdvisorMembership.ts create mode 100644 src/utilities/sameOriginRequest.ts create mode 100644 tests/int/campaignMunicipalityAdvisorMembership.int.spec.ts create mode 100644 tests/unit/municipalityAdvisorMembership.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 656840b9..f39d4e27 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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 "`) 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 ? … : 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 `` (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=`. 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. diff --git a/docs/plans/combobox-assessores-lista-municipios.md b/docs/plans/combobox-assessores-lista-municipios.md index 9e25fadc..651bb1a3 100644 --- a/docs/plans/combobox-assessores-lista-municipios.md +++ b/docs/plans/combobox-assessores-lista-municipios.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index c17679c6..dcbc8b8b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-26 (**B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); **B33** ordenação e filtro no header da lista de dobradinhas — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); **B28** e-mail + celular na lista de `/campanha/liderancas` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); **B27** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — a lista de checkboxes com botão "Salvar assessores" vira chips removíveis sobre um combobox com busca, gravando **por delta** (endpoint JSON espelho do `expected-votes/`, lock do B19 ✓), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); **B24** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` — tira o botão "Salvar" que o princípio 4 do `PRODUCT.md` proíbe e fecha o adiado "nota no popover" do B9, [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6) +Atualizado em: 2026-07-26 (**B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); **B33** ordenação e filtro no header da lista de dobradinhas — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); **B28** e-mail + celular na lista de `/campanha/liderancas` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); **B24** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` — tira o botão "Salvar" que o princípio 4 do `PRODUCT.md` proíbe e fecha o adiado "nota no popover" do B9, [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6) Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -101,7 +101,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **B24** auto-save + justificativa no popover de Tendência da lista de municípios — pedido de produto (2026-07-25): o popover da coluna "Tendência" perde o botão **"Salvar"** (grava sozinho: status na hora, justificativa com debounce que **cancela o timer anterior**, flush no blur/fechamento) e ganha o campo para **editar a justificativa** (`politicalTrend.note`, hoje hidden input na lista e só editável em `/[slug]/editar`). Fecha duas dívidas datadas: o anti-goal literal do princípio 4 do `PRODUCT.md` ("Save buttons on Popovers and quick-edit cells") — a célula vizinha da mesma linha (votos estimados) já grava sozinha — e o "nota de tendência no popover, gatilho: feedback de uso" adiado no **B9 ✓**. Persistência por endpoint JSON próprio (`POST /campanha/municipios/political-trend`, espelho do `expected-votes/`), **sem revalidar** a lista de 435 linhas por gravação; `setMunicipalityPoliticalTrend` e o access ficam intactos; sem migration/collection/Consent · ~0,5d · Janela 1–2 · Impeccable B · soft: B23 (se vier antes, a nota read-only do Popover vira o textarea) · cortável · [plano](plans/autosave-tendencia-lista-municipios.md) - **B25** link do território na lista de municípios — pedido de produto (2026-07-25): na coluna "Território" de `/campanha/municipios` o nome do TI vira link para **a página daquele território**. Hoje a célula é texto morto (`cell: (m) => m.region`) e a navegação é assimétrica: o painel do Início (**E17 ✓**) já linka TI → `/campanha/municipios?region=`, mas do município não se chega ao território. Destino = a página do **B21** ancorada na linha do TI (`/campanha/territorios#ti-` + `scroll-mt` e `target:` no precedente de `/campanha/conceitos`), **não** rota de detalhe `/campanha/territorios/` (Não escopo do B21, sem evidência nova: seria a linha do rollup + a lista filtrada, que já existem) nem `?region=` (tabela de uma linha destrói a comparação que é o job da tela). Âncora derivada por helper puro (`territoryAnchorId` sobre `slugify`), consumida pelas duas pontas e pinada por teste — TI não ganha slug persistido; sem migration/collection/action/query nova · ~0,25d · Janela 1–2 · Impeccable B · **dep dura: B21** · soft: B17 (coluna ocultável) · E12 é gatilho de revisitação da decisão "âncora vs rota de detalhe", não dependência · cortável · [plano](plans/link-territorio-lista-municipios.md) - ~~**B26** registrar sinal a partir da célula "Último sinal"~~ — **entregue 2026-07-25** (Popover desktop / Drawer mobile; `municipalityUpdate` `kind: 'sinal'`; submit explícito; `revalidateMunicipalityListPaths`; sem migration) · [plano](plans/registrar-sinal-lista-municipios.md) -- **B27** combobox + chips + auto-save no popover de Assessores da lista de municípios — pedido de produto (2026-07-25): na coluna "Assessores" de `/campanha/municipios`, o popover de edição rápida (**B9 ✓**) troca a **lista de checkboxes + botão "Salvar assessores"** por (a) **chips removíveis** dos assessores já atribuídos acima de (b) um **combobox** com busca acento-insensível e teclado, (c) gravando **na hora** a cada atribuição/remoção, com UI otimista. Três dívidas datadas num item só: o botão é o anti-goal literal do princípio 4 do `PRODUCT.md` (a célula vizinha da mesma linha já grava sozinha); a lista de checkboxes não escala com as contas semeadas pelo **E4R ✓**; e a seleção atual só se lê como iniciais no gatilho — o mesmo par entidade↔carteira já é chips + busca em `/campanha/assessores` (**B19 ✓**). Gravação **por delta** (`municipalityId`+`advisorId`+`assigned`) via endpoint JSON próprio espelhando `expected-votes/` — **sem revalidar** 435 linhas por clique e sem o replace do array (que sob auto-save apaga a edição concorrente); reusa o lock `municipality-advisors:{id}` e o cálculo puro do B19 (`nextAdvisorIdsAfterMembership` sobe para `src/lib/`), mas **não** o record daquela tela (`assertTargetAdvisor` recusa coordenador/candidato e o próprio ator — legais aqui). Combobox = `Command` já em uso, não primitivo novo; gatilho da célula intacto (contrato de tooltip do B23); UI segue coordenador-only (candidato = adiado com gatilho); sem migration/collection/Consent/query nova · ~0,75d · Janela 1–2 · Impeccable B · soft: B24 (mesmo padrão de endpoint; quem chegar primeiro extrai `isSameOriginRequest`), B23 (tooltip no mesmo gatilho — os chips melhoram o fallback de toque que aquele plano presumia), B26 (contrato deliberadamente diferente na mesma linha), B19 ✓/B16 ✓/B9 ✓/E4R ✓/E9 ✓ · cortável · [plano](plans/combobox-assessores-lista-municipios.md) +- ~~**B27** combobox + chips + auto-save no popover de Assessores da lista de municípios~~ — **entregue 2026-07-26** (chips removíveis + `Command` de busca acento-insensível + gravação **por delta**, sem botão "Salvar"; `nextAdvisorIdsAfterMembership` subiu para `src/lib/municipalityAdvisorMembership.ts`; endpoint `POST /campanha/municipios/advisors` espelha `expected-votes/`; `assignMunicipalityAdvisorsFormAction`/replace do array sobrevive só para `/editar`) · [plano](plans/combobox-assessores-lista-municipios.md) - **B28** e-mail + celular na lista de lideranças — pedido de produto (2026-07-25): em `/campanha/liderancas` adicionar colunas **E-mail** e **Celular** com o **mesmo funcionamento de leitura** da tabela de assessores (**B19 ✓**): clique copia (toast), “—” se vazio, celular com máscara BR, ícone WhatsApp (`wa.me`) por linha quando o número normaliza. Hoje o loader já traz `phone` no view model e o e-mail só no detalhe — a lista mostra Nome · Status · Municípios · Organizações · Acesso e obriga abrir a ficha para contactar. Extrair o primitivo de copy (+ helper WhatsApp de linha) para `shared/` no mesmo PR (2º call site do B19); `CampaignTable` permanece (não clonar `AdvisorsTable`). **Fora:** toggle “Editar” / debounce in-list (Contact update + locks = Adiado no plano); sem migration/collection/Consent/action de escrita · ~0,5–0,75d · Janela 1 · Impeccable B · soft: B19 ✓, B30 (mesma linha — ícone distinto) · cortável · [plano](plans/email-celular-lista-liderancas.md) - **B30** convidar para completar cadastro por WhatsApp na lista de lideranças — pedido de produto (2026-07-25): ação ao fim de cada linha de `/campanha/liderancas` disparando o **mesmo** fluxo que já existe no detalhe (`/campanha/liderancas/[id]` → `LeadershipInviteButtons`, `kind: 'autopreenchimento'`): gera `campaignInvite` (token único, 7 dias) via `createCampaignInvite` e mostra a âncora `wa.me` com a mensagem pronta, num `Popover` compacto (two-step reveal — sem `window.open` automático, que quebra com popup blocker após o `await`). Botão **disabled** quando `contact.phone` não passa em `normalizeBrazilianPhone` (mesmo critério de `AdvisorsTable`/B19 ✓), com `aria-label` explicando o motivo. Distinto do WhatsApp de contato do **B28** (que é atalho de chat sem token) — ícone diferente para não ambiguar as duas ações na mesma linha; nenhum dos dois bloqueia o outro. Reusa 100% da action/collection/access existentes; sem migration, sem endpoint, sem mudança de access · ~0,25–0,5d · Janela 1 · Impeccable B · soft: B28 (coordenar ícone na mesma linha) · cortável · [plano](plans/convite-whatsapp-lista-liderancas.md) - **B29** ordenação e filtro no header da lista de lideranças — pedido de produto (2026-07-25): levar para `/campanha/liderancas` o que a lista de municípios ganhou em **B15 ✓** (ordenar clicando no header, `?sort=`/`?dir=` na URL) e **B16 ✓** (filtro multi-seleção em Popover no header, com busca, chips e "Limpar"). Hoje o estado da URL é só `q`+`page` e a ordem é fixa em `-updatedAt` — que, com o seed do **E4R ✓**, é a ordem em que a planilha gravou as fichas. Filtros v1 = **as colunas visíveis** (Status de apoio, Setor, Município com facet no escopo do ator, Acesso ao app), entrando as colunas "Setor" e "Última atualização" (ambas já no view model, hoje não renderizadas) para não haver ordenação invisível; ordenação **no banco**, incluindo nome por join `contact.name` (o adapter resolve caminho pontilhado em `buildOrderBy`) — **sem** desnormalizar nome na `leadership` (segunda fonte de verdade de pessoa é anti-goal do AGENTS; se o join não sair, corta-se o sort por nome, não a normalização). Corrige de passagem o teto mudo de `limit: 200` na pré-resolução de contatos da busca. É o **3º call site** do header rico (municípios ✓ · **B21** · este), o gatilho que o B21 registrou: extrai `CampaignSortableHead` + `CampaignHeaderFilterPopover` para `shared/` (só chrome — estado/optimistic/hrefs ficam nos wrappers de domínio) e migra municípios no mesmo PR; dá a B22/B23 um único lugar onde pousar. Sem migration/collection/Consent/action · ~1,5–2d · Janela 1–2 · Impeccable B · soft: B21 (quem entrar 2º duplica; B29 migra os três), B28 (mesma tabela — colunas de contato ficam sem head rico), B22/B23 (primitivo), B17/B18 (colunas já são dado; URL nasce salvável), E14 (níveis pousam aqui) · cortável · [plano](plans/ordenacao-filtros-lista-liderancas.md) @@ -166,7 +166,7 @@ flowchart TD B24n["B24 Auto-save + justificativa
no popover de Tendência"] B25n["B25 Link do território
na lista de municípios"] B26n["B26 ✓ Registrar sinal
na célula Último sinal"] - B27n["B27 Combobox + chips
no popover de Assessores"] + B27n["B27 ✓ Combobox + chips
no popover de Assessores"] B28n["B28 E-mail + celular
na lista de lideranças"] B30n["B30 Convite WhatsApp
na lista de lideranças"] B31n["B31 Coluna de dobradinhas
na lista de lideranças"] diff --git a/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts b/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts new file mode 100644 index 00000000..bb824c40 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts @@ -0,0 +1,81 @@ +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 { + CAMPAIGN_SESSION_EXPIRED_MESSAGE, + mapCampaignFormActionError, +} from '@/utilities/campaignFormActionError' +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> { + if (!isSameOriginRequest(request)) { + 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 }, + ) + } + + try { + const { municipalityId, advisorId, assigned } = bodySchema.parse(json) + 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) { + const mapped = mapCampaignFormActionError({ + error, + safeMessages: [ + ...MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES, + 'Autenticação necessária.', + CAMPAIGN_SESSION_EXPIRED_MESSAGE, + ], + genericMessage: + 'Não foi possível atualizar os assessores. 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 atualizar os assessores.', + }, + { status: isAuthError ? 401 : 400 }, + ) + } +} diff --git a/src/app/(campaign)/campanha/(app)/municipios/advisors/types.ts b/src/app/(campaign)/campanha/(app)/municipios/advisors/types.ts new file mode 100644 index 00000000..90856c4a --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipios/advisors/types.ts @@ -0,0 +1,10 @@ +export type MunicipalityListAdvisorsResponse = + | { + status: 'success' + message: string + advisors: number[] + } + | { + status: 'error' + message: string + } diff --git a/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts b/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts index 8ce52719..5884b756 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts @@ -9,6 +9,7 @@ import { CAMPAIGN_SESSION_EXPIRED_MESSAGE, mapCampaignFormActionError, } from '@/utilities/campaignFormActionError' +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> { diff --git a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts index 0d95ecde..35bdfa38 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts @@ -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.', }) diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx index 1c63db94..24788576 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx @@ -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} /> diff --git a/src/app/(campaign)/campanha/actions/advisor.ts b/src/app/(campaign)/campanha/actions/advisor.ts index 0349446d..00537d6f 100644 --- a/src/app/(campaign)/campanha/actions/advisor.ts +++ b/src/app/(campaign)/campanha/actions/advisor.ts @@ -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 diff --git a/src/app/(campaign)/campanha/actions/municipality.ts b/src/app/(campaign)/campanha/actions/municipality.ts index a567cdc7..4b42616f 100644 --- a/src/app/(campaign)/campanha/actions/municipality.ts +++ b/src/app/(campaign)/campanha/actions/municipality.ts @@ -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) +} diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index 642f5414..2dadab8f 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -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> => [ { @@ -372,12 +370,10 @@ const municipalityListColumns = ({ isCoordinator ? ( ) : ( { {isCoordinator ? ( ) : names.length ? ( names.join(', ') diff --git a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx index e5ea45c0..52b3d50a 100644 --- a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx @@ -1,67 +1,173 @@ '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, MunicipalityAdvisorAvatarStack, } 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 options: EligibleAdvisorOption[] - formAction: ( - state: CampaignFormActionState, - formData: FormData, - ) => Promise } 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(currentAdvisorIDs) + const [query, setQuery] = useState('') + const [errorMessage, setErrorMessage] = useState(null) + const [isPending, setIsPending] = useState(false) + const pendingCountRef = useRef(0) + const lastPropsIDsRef = useRef(currentAdvisorIDs) + // Requests can settle out of send order (network jitter). Track the send + // order explicitly and only ever adopt the response with the highest + // sequence number seen so far — never "whichever response happens to be + // the one that brings the pending count to zero". + 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 + // chips the popover just added — no round trip needed to label them. + const optionNameById = useMemo( + () => new Map(options.map((option) => [option.id, option.name])), + [options], + ) + + const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]) + const chips = useMemo( + () => + selectedIDs + .map((id) => ({ + id, + name: optionNameById.get(id) ?? advisorNamesById.get(id)?.name ?? `Assessor #${id}`, + })) + .sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')), + [selectedIDs, optionNameById, advisorNamesById], + ) + + // Redundant with the chips themselves, but B23's hover/focus tooltip on the + // trigger stays here so the coordinator sees the full list without opening + // the popover — `formatAdvisorNamesTooltip` returns `null` for an empty + // list (the trigger already reads "Sem responsável"/"Sem assessor" by + // extenso, so a tooltip would only echo it). + 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] + }) + } + + 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 + pendingCountRef.current = Math.max(0, pendingCountRef.current - 1) + + if (!response.ok || payload.status !== 'success') { + revertDelta() + setErrorMessage(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE) + return + } + + if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) { + latestConfirmedRef.current = { seq: requestSeq, advisors: payload.advisors } + } + + // Only adopt the server's confirmed set once every in-flight delta has + // settled, and always the most recently *sent* delta's response (not + // whichever response happens to be the one landing last — responses + // can arrive out of send order under network jitter). + if (pendingCountRef.current === 0 && latestConfirmedRef.current) { + lastPropsIDsRef.current = latestConfirmedRef.current.advisors + setSelectedIDs(latestConfirmedRef.current.advisors) + } + } catch { + pendingCountRef.current = Math.max(0, pendingCountRef.current - 1) + revertDelta() + setErrorMessage(SAVE_ERROR_MESSAGE) + } finally { + if (pendingCountRef.current === 0) setIsPending(false) + } + })() + } + + const statusMessage = errorMessage ? errorMessage : isPending ? 'Salvando assessores.' : '' return ( - {/* 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). */} - -
- - -

+ +

+

Atribuir assessores

+

O assessor vê e gerencia somente os municípios que administra.

-
    - {options.map((option) => ( -
  • - -
  • - ))} -
- {state.message && state.status !== 'success' ? ( - - {state.message} - + {isPending ? ( + ) : null} - - +
+ {chips.length > 0 ? ( +
+ {chips.map((chip) => ( + + ))} +
+ ) : null} + + + + {filteredOptions.length === 0 ? ( +

+ Nenhum resultado. +

+ ) : ( + filteredOptions.map((option) => { + const assigned = selectedSet.has(option.id) + return ( + toggle(option.id, !assigned)} + > + + {option.name} + {option.isCurrent ? ' (você)' : ''} + + + ) + }) + )} +
+
+ {errorMessage ? ( +
+ + {errorMessage} + +
+ ) : null} +

+ {statusMessage} +

) diff --git a/src/lib/municipalityAdvisorMembership.ts b/src/lib/municipalityAdvisorMembership.ts new file mode 100644 index 00000000..327fa15c --- /dev/null +++ b/src/lib/municipalityAdvisorMembership.ts @@ -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) +} diff --git a/src/lib/schemas/municipality.ts b/src/lib/schemas/municipality.ts index 18cd666a..b8d42df7 100644 --- a/src/lib/schemas/municipality.ts +++ b/src/lib/schemas/municipality.ts @@ -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 +export type MunicipalityAdvisorMembershipInput = z.input export type MunicipalityExpectedVotesInput = z.input diff --git a/src/utilities/relationship.ts b/src/utilities/relationship.ts index 50b20e30..fa2314a5 100644 --- a/src/utilities/relationship.ts +++ b/src/utilities/relationship.ts @@ -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)), +] diff --git a/src/utilities/sameOriginRequest.ts b/src/utilities/sameOriginRequest.ts new file mode 100644 index 00000000..4c35a474 --- /dev/null +++ b/src/utilities/sameOriginRequest.ts @@ -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 + } +} diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index 643f2db3..bd65cd5c 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -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, diff --git a/tests/e2e/setup.e2e.spec.ts b/tests/e2e/setup.e2e.spec.ts index 5acae4a3..d69f3bf7 100644 --- a/tests/e2e/setup.e2e.spec.ts +++ b/tests/e2e/setup.e2e.spec.ts @@ -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) + } }) diff --git a/tests/int/campaignMunicipalityAdvisorMembership.int.spec.ts b/tests/int/campaignMunicipalityAdvisorMembership.int.spec.ts new file mode 100644 index 00000000..f766423c --- /dev/null +++ b/tests/int/campaignMunicipalityAdvisorMembership.int.spec.ts @@ -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) + }) +}) diff --git a/tests/unit/municipalityAdvisorMembership.unit.spec.ts b/tests/unit/municipalityAdvisorMembership.unit.spec.ts new file mode 100644 index 00000000..9b50b7a5 --- /dev/null +++ b/tests/unit/municipalityAdvisorMembership.unit.spec.ts @@ -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.`, + ) + }) +}) From a4b62c1a9ade02c3b58d54cb39c0fc2646ff09b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 03:34:11 -0300 Subject: [PATCH 2/3] fix(test): drop obsolete municipalitySlug/formAction props from MunicipalityListAdvisorsControl test after B27 rebase merge --- tests/unit/campaignComponents.unit.spec.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index 3b10a76f..3aaa6a44 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -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"') From 2bbb630af60eae2950db3108e57b2ead632e3b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 03:51:32 -0300 Subject: [PATCH 3/3] simplify(B27): fix optimistic-state clobber, unify JSON route error shell, dedupe advisor id resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MunicipalityListAdvisorsControl: stop advancing lastPropsIDsRef from a successful delta response — it exists only to detect a genuine external prop change, and writing it there made a later stale re-render of the pre-edit content look external and clobber optimistic state back to it. Also reconcile with the latest confirmed server set on the error path once every in-flight delta settles (previously only the success path did), and consolidate chip/tooltip name resolution onto the shared advisorEntriesFromIds helper instead of a bespoke lookup with an '#id' placeholder. - Extract campaignJsonMutationErrorResponse/parseCampaignJsonRequestBody (src/utilities/campaignJsonMutationRoute.ts) and adopt them in both municipios/advisors/route.ts and municipios/expected-votes/route.ts, removing the duplicated body-parse + error-mapping ladder. - Municipality.ts's validateMunicipalityAdvisors now uses uniqueRelationshipIds instead of re-inlining the Set+map+filter dance the helper was extracted for. Verified: tsc --noEmit, lint, format:check, check:cycles all clean; full unit suite (450 tests) and the municipality/advisor int suites pass. knip's pre-existing payload.config.ts load error is unrelated (P3). Playwright's spawned webServer failed to boot in this sandbox (unrelated module-resolution environment issue, reproduced even before this diff); manual pnpm dev boots fine and the change is covered by unit/int tests. --- .../(app)/municipios/advisors/route.ts | 41 ++-------- .../(app)/municipios/expected-votes/route.ts | 41 ++-------- src/collections/Municipality.ts | 6 +- .../MunicipalityListAdvisorsControl.tsx | 77 ++++++++++--------- src/utilities/campaignJsonMutationRoute.ts | 57 ++++++++++++++ 5 files changed, 117 insertions(+), 105 deletions(-) create mode 100644 src/utilities/campaignJsonMutationRoute.ts diff --git a/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts b/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts index bb824c40..2900911f 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts @@ -5,9 +5,9 @@ import { setMunicipalityAdvisorMembership } from '@/app/(campaign)/campanha/acti import { MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES } from '@/lib/schemas/municipality' import { positiveRelationshipId } from '@/lib/schemas/primitives' import { - CAMPAIGN_SESSION_EXPIRED_MESSAGE, - mapCampaignFormActionError, -} from '@/utilities/campaignFormActionError' + campaignJsonMutationErrorResponse, + parseCampaignJsonRequestBody, +} from '@/utilities/campaignJsonMutationRoute' import { uniqueRelationshipIds } from '@/utilities/relationship' import { isSameOriginRequest } from '@/utilities/sameOriginRequest' @@ -30,18 +30,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, advisorId, assigned } = bodySchema.parse(json) + const { municipalityId, advisorId, assigned } = bodySchema.parse(parsed.body) const updated = await setMunicipalityAdvisorMembership({ municipality: municipalityId, advisor: advisorId, @@ -54,28 +47,10 @@ export async function POST( advisors: uniqueRelationshipIds(updated.advisors), }) } catch (error) { - const mapped = mapCampaignFormActionError({ - error, - safeMessages: [ - ...MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES, - 'Autenticação necessária.', - CAMPAIGN_SESSION_EXPIRED_MESSAGE, - ], + return campaignJsonMutationErrorResponse(error, { + safeMessages: MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES, genericMessage: 'Não foi possível atualizar os assessores. 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 atualizar os assessores.', - }, - { status: isAuthError ? 401 : 400 }, - ) } } diff --git a/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts b/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts index 5884b756..5ca33cd2 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts @@ -6,9 +6,9 @@ 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' @@ -36,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, @@ -59,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 }, - ) } } diff --git a/src/collections/Municipality.ts b/src/collections/Municipality.ts index 5653a9d1..0c5c3366 100644 --- a/src/collections/Municipality.ts +++ b/src/collections/Municipality.ts @@ -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({ diff --git a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx index 52b3d50a..2f65cd0a 100644 --- a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { MunicipalityListAdvisorsResponse } from '@/app/(campaign)/campanha/(app)/municipios/advisors/types' import { + advisorEntriesFromIds, formatAdvisorNamesTooltip, MunicipalityAdvisorAvatarStack, } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack' @@ -53,10 +54,15 @@ export const MunicipalityListAdvisorsControl = ({ const [isPending, setIsPending] = useState(false) const pendingCountRef = useRef(0) const lastPropsIDsRef = useRef(currentAdvisorIDs) - // Requests can settle out of send order (network jitter). Track the send - // order explicitly and only ever adopt the response with the highest - // sequence number seen so far — never "whichever response happens to be - // the one that brings the pending count to zero". + // 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) @@ -70,30 +76,29 @@ export const MunicipalityListAdvisorsControl = ({ }, [currentAdvisorIDs]) // Every eligible account (coordinator/advisor/candidate) is listed here - // regardless of current assignment, so it doubles as the name lookup for - // chips the popover just added — no round trip needed to label them. - const optionNameById = useMemo( - () => new Map(options.map((option) => [option.id, option.name])), - [options], - ) + // 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(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( () => - selectedIDs - .map((id) => ({ - id, - name: optionNameById.get(id) ?? advisorNamesById.get(id)?.name ?? `Assessor #${id}`, - })) - .sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')), - [selectedIDs, optionNameById, advisorNamesById], + [...advisorEntriesFromIds(selectedIDs, advisorLookup)].sort((left, right) => + left.name.localeCompare(right.name, 'pt-BR'), + ), + [selectedIDs, advisorLookup], ) - // Redundant with the chips themselves, but B23's hover/focus tooltip on the - // trigger stays here so the coordinator sees the full list without opening - // the popover — `formatAdvisorNamesTooltip` returns `null` for an empty - // list (the trigger already reads "Sem responsável"/"Sem assessor" by - // extenso, so a tooltip would only echo it). + // 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( @@ -125,6 +130,18 @@ export const MunicipalityListAdvisorsControl = ({ }) } + // 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', @@ -134,32 +151,22 @@ export const MunicipalityListAdvisorsControl = ({ }) const payload = (await response.json()) as MunicipalityListAdvisorsResponse - pendingCountRef.current = Math.max(0, pendingCountRef.current - 1) 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 } } - - // Only adopt the server's confirmed set once every in-flight delta has - // settled, and always the most recently *sent* delta's response (not - // whichever response happens to be the one landing last — responses - // can arrive out of send order under network jitter). - if (pendingCountRef.current === 0 && latestConfirmedRef.current) { - lastPropsIDsRef.current = latestConfirmedRef.current.advisors - setSelectedIDs(latestConfirmedRef.current.advisors) - } + finishRequest() } catch { - pendingCountRef.current = Math.max(0, pendingCountRef.current - 1) revertDelta() setErrorMessage(SAVE_ERROR_MESSAGE) - } finally { - if (pendingCountRef.current === 0) setIsPending(false) + finishRequest() } })() } diff --git a/src/utilities/campaignJsonMutationRoute.ts b/src/utilities/campaignJsonMutationRoute.ts new file mode 100644 index 00000000..7f6cfc85 --- /dev/null +++ b/src/utilities/campaignJsonMutationRoute.ts @@ -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 } +> => { + 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 => { + 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 }, + ) +}