mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge branch 'cursor/70386841'
This commit is contained in:
commit
b16d2c9831
23 changed files with 761 additions and 172 deletions
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **B27 entregue em código (2026-07-26):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — trocou a lista de checkboxes + botão "Salvar assessores" por chips removíveis (`Badge`+`XIcon`, `aria-label="Remover <nome>"`) acima de um `Command` (`shouldFilter={false}` + `matchesAtWordStart`, acento-insensível), com gravação **por delta** (um toggle por vez, não replace do array). Fase 1 — `nextAdvisorIdsAfterMembership` subiu de `actions/advisor.ts` para `src/lib/municipalityAdvisorMembership.ts` (compartilhado com a carteira do B19); `setMunicipalityAdvisorMembershipRecord`/`setMunicipalityAdvisorMembership` em `actions/municipality.ts` (mesma transação + lock `municipality-advisors:{id}` + `reloadUnrestrictedActor`, mas sem `assertTargetAdvisor` — o alvo pode ser coordenador/candidato, legal nesta tela). Fase 2 — endpoint `POST /campanha/municipios/advisors` espelhando `expected-votes/route.ts` (`isSameOriginRequest` local, zod, `mapCampaignFormActionError`). Fase 3 — `MunicipalityListAdvisorsControl.tsx` reescrito: estado otimista + contador de pendências (reconcilia só quando toda entrega em voo assentou, nunca aborta um delta anterior), `Alert` de erro, `aria-live` `sr-only`; polish do `/impeccable` alinhou o gatilho (`aria-expanded`/`aria-haspopup`/realce `bg-muted/60` ao abrir) ao padrão do `MunicipalityListExpectedVotesControl` irmão (B24). `assignMunicipalityAdvisorsRecord` (replace do array) sobrevive só para `/editar`. Achado de infraestrutura no caminho: o Playwright `webServer` (`pnpm dev`) recompila uma rota POST-only no primeiro hit e o Fast Refresh resultante aborta o fetch em voo do teste (`ERR_ABORTED`) — `tests/e2e/setup.e2e.spec.ts` ganhou um prewarm dummy de `/campanha/municipios/advisors` (e retroativamente de `expected-votes/`, mesmo padrão) antes dos specs reais. Gate verde: tsc/lint/format/`check:cycles` limpos, 393 unit+int (6 novos), e2e do fluxo (isolado e em suíte) verde — falhas observadas em rodadas full-suite eram flakiness de ambiente sob carga sustentada (testes não relacionados falhando de forma rotativa, sempre verdes isolados), não regressão do B27; build produção ok; Aikido 0 achados nos 9 arquivos novos/editados. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md).
|
||||
- **B23 entregue em código (2026-07-26):** tooltip de conteúdo nas células das listas — a mecânica genérica (`cellTooltip` em `CampaignTableColumn`, `CampaignCellTooltip`, `CampaignHoverTooltip` promovido para `shared/`) já tinha saído de graça no **E10 ✓**; este item fechou o que faltava: `openOnTouch`/`disabled` em `CampaignHoverTooltip` (default `true`/`false`, sem mudança nos 4 call sites pré-B23) para conviver com um Popover no mesmo gatilho sem roubar o tap nem sobreviver atrás dele — e, no `/simplify`, ganhou um terceiro comportamento: `content` falsy renderiza o `children` sem wrapper, então os dois consumidores abaixo não precisam mais de um ternário `content ? <CampaignHoverTooltip>… : children` cada; `formatAdvisorNamesTooltip` + `advisorEntriesFromIds` (dedupe do id→nome entre a lista e o controle) junto de `MunicipalityAdvisorAvatarStack.tsx`; e os dois consumidores de `/campanha/municipios` — célula "Assessores" (não-coordenador via `cellTooltip`; coordenador envolvendo o próprio `PopoverTrigger`, `openOnTouch={false}`/`disabled={open}`, nunca os dois ao mesmo tempo) e célula "Tendência" (tooltip só quando há `politicalTrendNote`; em toque a nota aparece como leitura dentro do próprio Popover, acima do select). Verificado manualmente em `/campanha/municipios` como coordenador e assessor: hover mostra nomes/justificativa, foco de teclado abre a mesma tooltip, clique/tap abre o Popover sem tooltip órfã atrás. Gate verde (tsc/lint/format/knip — P3 pré-existente/check:cycles/443 unit/387 int/build); Aikido 0 findings. Plano: [`tooltip-celulas-listas.md`](../../../docs/plans/tooltip-celulas-listas.md).
|
||||
- **B22 entregue em código (2026-07-26):** explicação por coluna no header de `/campanha/municipios` — `description?: ReactNode` em `CampaignTableHead` (`shared/CampaignTable.tsx`) e rename `tooltip`→`description` em `MunicipalitySortableHead`, ambos com o mesmo sublinhado pontilhado no label e o mesmo `CampaignHoverTooltip` compartilhado (já promovido para `shared/` pelo **E10 ✓**) — sem componente novo. Auditoria pré-implementação achou o escopo já reduzido: o primitivo de tooltip e o `cellTooltip` genérico já existiam (E10), então só faltava a prop em `CampaignTableHead` e o rename. Novo helper `campaignConceptOneLiner(id)` ao lado de `campaignConceptHref` em `campaignIntelligenceConcepts.ts` — fonte única para as colunas cobertas pelo **E18 ✓** (`expectedVotes` lê `oneLiner('meta')`, `goalCoverage` lê `oneLiner('cobertura-da-meta')` + a nota de cenário). Novo `MunicipalityListColumnId` + `municipalityColumnDescriptions` (`Record` de 11 entradas) em `municipalityLabels.ts` — a coluna "Classe" mantém o texto mais longo (`CLASS_COLUMN_DESCRIPTION`, movido de `MunicipalityList.tsx`) em vez do `oneLiner` terso de `classe-territorial`, decisão assumida porque uma classificação de 5 vias não cabe numa frase. `MunicipalityList.tsx` perdeu os três `*Hint` locais e a intersecção de tipo extra em `municipalityListColumns()`. Testes: pin do `campaignConceptOneLiner`, `describe('CampaignTableHead description')` (sem prop = render idêntico; com prop = 1 trigger, conteúdo fora do markup do servidor, mesmo contrato do `cellTooltip`), e contagem de `data-slot="tooltip-trigger"` no `<thead>` (10 staff / 5 leader) em `campaignComponents.unit.spec.ts`. Gate verde (tsc, lint, format, check:cycles, 442 unit / 387 int, build); `knip` segue com o erro pré-existente ao carregar `payload.config.ts` (ledgerado P3, confirmado idêntico fora desta mudança); Aikido sem achado. Fora do escopo (mantido do plano): copy nas outras 5 listas, tooltip nos cards mobile, variante Popover no touch. Plano: [`explicacao-colunas-header-listas.md`](../../../docs/plans/explicacao-colunas-header-listas.md).
|
||||
- **C13 entregue em código (2026-07-25):** "Plano de Ação" → **"Atividade"** em toda a vertical. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades", hooks internos renomeados), campo `campaignDemand.actionPlan` → `activity`, 7 utilities `actionPlan*` → `activity*`, `access/actionPlans.ts` → `access/activities.ts`, `lib/schemas/actionPlan.ts` → `activity.ts`, `actions/actionPlan.ts` → `activity.ts`, `components/campaign/actionPlan/` → `activity/` (13 componentes), rota `(app)/planos/` → `(app)/atividades/` (criação em `atividades/nova`, feminino como `demandas/nova`) e sidebar "Atividades". **Migration data-preserving escrita à mão** `20260725_213000_rename_action_plan_to_activity`: 4 tabelas, 3 enums, 2 colunas, 39 índices (4 pkeys inclusos), 14 FKs, 2 sequences — lista canônica tirada de `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` porque o snapshot do drizzle não conhece o índice parcial `action_plan_upcoming_start_at_idx` (escrito à mão no C8) e porque o gerador do Payload emitiria DROP+CREATE. O `.json` foi derivado do snapshot anterior por transformação textual (o `migrate:create` é interativo para renames) e validado pelo teste que importa: `pnpm migrate:create __probe` responde "No schema changes detected". **Bônus aprovado pelo usuário nesta sessão:** os **8 índices fósseis `%plaza%`** da remodelagem de 23/07 (que renomeou as colunas `plaza_id` mas não os índices, deixando banco e snapshot em desacordo) foram alinhados na mesma migration; `down()` os restaura, para não inventar um estado que nunca existiu. Decisões de vocabulário: sidebar **"Atividades"** (uma palavra só, a da entidade) e rótulos de campo alinhados — **"Tipo de atividade"**, **"Origem da atividade"**, **"Resultado da atividade"** — porque "ação" ao lado de "atividade" reintroduzia a tradução mental que o item existe para matar; valores de enum `kind`/`status`/`origin` **inalterados**. **Guard:** o describe "banned campaign terminology" de `codebaseConventions.unit.spec.ts` virou tabela de termos varrendo `src`+`tests`+`scripts` (migrations fora — história congelada), banindo os compostos `actionPlan`, a copy "plano de ação" e a rota velha, mas **não** `plan`/`plano` isolado (colidiria com os planos de assinatura da Fase 2 white-label); allowlist: o próprio spec, `scripts/generate-remodel-municipalities-migration.mjs` e o comentário histórico em `campaignMigrationReconciliation.int.spec.ts`. Estendê-lo a `tests/` derrubou 8 fósseis "Praça" em fixtures, limpos no mesmo PR. Gate verde (tsc, lint, format, knip, check:cycles, 46 unit / 48 int / 7 e2e, build); Aikido sem achado. **Deploy:** o `pnpm build` da Vercel aplica a migration enquanto o deployment antigo ainda serve — deployar fora do horário de mesa (mesmo perfil da remodelagem de 23/07). **Pós-`/simplify`:** a varredura mecânica comeu uma interpolação — `tests/unit/recentVisits.unit.spec.ts` ficou com `label: \`Município \`` para todas as entradas, e passava porque o label não era asserido (restaurado, com assertion nova para fechar a brecha, e os slugs `praca-` do fixture alinhados). O guard ganhou tolerância a espaço (`action[\s_-]?plan`), que era o buraco por onde `access/shared.ts` mantinha "action plan" em prosa; com isso a allowlist encolheu — o comentário de `campaignMigrationReconciliation.int.spec.ts` passou a citar `activity` e o arquivo saiu da exceção (uma spec que cresce não fica fora do guard por causa de um comentário). Deliberadamente **não** ampliei o padrão de "Praça" para a forma sem cedilha: cobraria três entradas novas de allowlist (nomes reais de organização na planilha, o teste de entradas legadas `kind: 'nucleus'`) por um fóssil já corrigido. Também: três resíduos do find-and-replace (banner `// Action activities`, `DOSSIER_*_PLAN_LIMIT`, "a activity" em dois comentários), concordância em pt-BR ("Criada por", "atividades apoiadas" minúsculo), o `??` impossível em `ActivityForm`, e as duas buscas de FK da migration agora usam `to_regclass` em vez de `::regclass` (que lança em vez de retornar NULL, contradizendo a invariante do próprio cabeçalho). A entrada morta `action_plan_municipality_idx` saiu da lista de índices da migration (nenhuma migration jamais criou esse nome — só os snapshots o afirmam, e com `push: false` nenhum banco pode carregá-lo), deixando `up()` e `down()` com os mesmos 48 pares; e o detalhe de organização parou de derivar o rótulo de status à mão e passou a reusar `ActivityStatusBadge`, com `OrganizationDetailViewModel.activities[].status` tipado como `ActivityStatus` em vez de `string` (a origem que forçava o re-narrowing no consumidor). Plano: [`renomear-plano-acao-para-atividade.md`](../../../docs/plans/renomear-plano-acao-para-atividade.md).
|
||||
|
|
@ -14,7 +15,6 @@ alwaysApply: false
|
|||
- **B20 entregue (2026-07-25):** Municípios prioritários no Início — `pickDashboardPriorityMunicipalities` (`src/utilities/dashboardPriorityMunicipalities.ts`) ordena `priority=alta` por déficit E9/`central`, amostra 8; `CampaignDashboard` exibe Badge com `highPriorityCount`, CTA “Ver todas” → `?priority=alta`, chips com `list-none`. Unit `dashboardPriorityMunicipalities.unit.spec.ts`. Plano: [`municipios-prioritarios-inicio.md`](../../../docs/plans/municipios-prioritarios-inicio.md).
|
||||
- **B21 entregue (2026-07-25):** nova rota staff `/campanha/territorios` com os 27 Territórios de Identidade no sistema de listas — sort/filtros/busca canônicos na URL, sub-linhas do Metropolitano achatadas sem perder a hierarquia, tabela mobile com primeira coluna sticky e drill para `/campanha/municipios?region=<TI>`. O chrome de header rico foi extraído para `shared/CampaignSortableHead` + `CampaignHeaderFilterPopover`, com os wrappers de municípios migrados sem mudança de contrato; B29/B33 agora só consomem essa peça. O painel de TI saiu do Início por decisão desta sessão. Sem migration/collection/action. Plano: [`pagina-territorios-identidade.md`](../../../docs/plans/pagina-territorios-identidade.md).
|
||||
- **B28 registrado (2026-07-25):** e-mail + celular na lista `/campanha/liderancas` — colunas com copy-on-click + WhatsApp (`wa.me`) no padrão de `/campanha/assessores` (B19 ✓); `email` no view model da lista; extrair primitivo compartilhado (2º call site); **sem** toggle Editar in-list (Contact update adiado). Appetite ~0,5–0,75d; Janela 1; cortável; Impeccable B. Plano: [`email-celular-lista-liderancas.md`](../../../docs/plans/email-celular-lista-liderancas.md).
|
||||
- **B27 registrado (2026-07-25):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (pedido de produto). Hoje: lista de checkboxes de todas as contas elegíveis + botão "Salvar assessores" (`MunicipalityListAdvisorsControl`, B9) → replace do array + `revalidateMunicipalityListPaths`. Plano: chips removíveis acima de um `Command` com busca acento-insensível; gravação **por delta** em endpoint JSON espelho do `expected-votes/` (sem revalidar 435 linhas), reusando o lock `municipality-advisors:{id}` e o cálculo puro do B19 (`nextAdvisorIdsAfterMembership` sobe para `src/lib/`) — mas **não** `setAdvisorMunicipalityMembership`, cujo `assertTargetAdvisor` recusa coordenador/candidato e o próprio ator (legais nesta tela). Gatilho da célula intacto (contrato de tooltip do B23); UI segue coordenador-only (candidato = adiado com gatilho, servidor já permite desde B19 ✓). Appetite ~0,75d; Janela 1–2; cortável; Impeccable B. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md).
|
||||
- **Fill-in registrado (2026-07-25):** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); só lista + label de sort `deficit`; glossário/KPI strips intactos. Soft B16 ✓ (liberou o nome). Appetite ~0,25d; Janela 1; cortável; Impeccable B. Plano: [`renomear-coluna-cobertura-meta.md`](../../../docs/plans/renomear-coluna-cobertura-meta.md).
|
||||
- **Fill-in registrado (2026-07-25):** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtrar ZE = filtrar Salvador (19 slugs). Remove coluna + filtro/sort UI + `?kind=` do contrato URL; schema/catálogo `kind` intacto (mapa, B8, dossiê). Appetite ~0,25–0,5d; Janela 1; cortável; Impeccable B. Plano: [`remover-coluna-tipo-municipios.md`](../../../docs/plans/remover-coluna-tipo-municipios.md).
|
||||
- **Pass 2 — Consolidação de engenharia entregue (2026-07-25, W0–W5 + D3):** tudo que foi one-shotted desde o Pass 1 virou infraestrutura compartilhada e arquitetura documentada. **W1 sistema de listas:** colunas como dado em `shared/CampaignTable.tsx` (seams `mandatory`/`defaultVisible` deixam **B17 trivial**; serialização canônica congelada deixa **B18 trivial**), o god module `municipalityUi.ts` (765 linhas/59 exports) dividido em `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`, 8 superfícies migradas com URLs byte-idênticas (pins do W0 verdes) e **B16+ absorvido** (`useOptimistic` no popover, facet por slugs, hrefs via serializador canônico com pin de byte-igualdade, facets no mesmo `Promise.all`); fora por decisão D5: planos (cards), `TerritoryOverviewTable`, comparação, LeaderContacts, preview de import. **W2 fronteiras:** `slug`/`phone`/`wordStartFilter`/`voteEstimate` → `lib/`, chaves de consent em `lib/campaignConsentKeys.ts`, 21 loaders `server-only`, `lib/campaignRoles.ts` client-safe (sidebar sem o barrel), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em **15 subpastas de domínio**; decisão D1: **sem `src/domains/`**, sem ports-and-adapters (`utilities/` adiado com gatilho: 3º módulo novo de um domínio num mês). **W4a:** `electionInsights.ts` (941 linhas, 2 exports vivos) **deletado** — sobreviventes em `electionFormat.ts`/`voteTrend.ts`; **E10 nasce em módulo novo**. **W4b:** knip `exports`/`types`/`enumMembers` em **ERROR** no CI, 0 findings (kit ui sem peças mortas; `duplicates` off — únicos hits eram os 6 aliases deliberados `canUpdateX = canReadX`). **W4c:** tabs de detalhe extraídas + factory `detailTabUi`. **W4d:** `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções comentadas). **W4e:** tipos de fonte única (cenários, trend, strategy VM nomeado). **W4f:** copy Praça→Município varrida (~70 strings; apoiadores desambiguado: cidade do contato = "Cidade"). **D3:** `submitWhatsapp` resolve `Consent.key='whatsapp-inscricao'` fail-closed; migration `20260725_170000` tagueia o doc vivo — **revisar o SQL antes de push/deploy**. Artefatos novos: `docs/ARCHITECTURE.md` + `.cursor/rules/codebase-map.mdc` (atualizados no mesmo PR que mover coisas). Suites: 46 unit / 48 int / 7 e2e; gates verdes por entrega. Tracker: [`IMPROVE-CODE-QUALITY-PLAN.md`](../../../docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Combobox, chips e auto-save no popover de Assessores da lista de municípios
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Status: entregue
|
||||
Atualizado em: 2026-07-26
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B27**)
|
||||
Impeccable: B — encaixe no `MunicipalityListAdvisorsControl` (tabela desktop + card mobile de `/campanha/municipios`); sem rota nova de UI
|
||||
Appetite: ~0,75 dia eng; 1 componente reescrito + 1 route handler JSON + 1 record de delta + 1 helper puro movido; sem migration
|
||||
|
|
@ -122,8 +122,8 @@ Componentes:
|
|||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B, B27; grafo; Janela 1–2; cortes seguros)
|
||||
- `src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx` — superfície alvo (checkbox list + "Salvar assessores")
|
||||
- `src/components/campaign/municipality/MunicipalityList.tsx` (~290–320 e card mobile ~483–494) — os dois pontos de uso do controle
|
||||
- `src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx` — superfície entregue (chips + `Command` + auto-save por delta; antiga checkbox list + "Salvar assessores" removida)
|
||||
- `src/components/campaign/municipality/MunicipalityList.tsx` (~378 e card mobile ~583) — os dois pontos de uso do controle (números de linha corrigidos nesta entrega)
|
||||
- `src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx` — máquina de auto-save (pendência, rollback, live region) a espelhar
|
||||
- `src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts` e `types.ts` — contrato do endpoint e `isSameOriginRequest`
|
||||
- `src/components/campaign/advisor/AdvisorMunicipalityCell.tsx` — chips removíveis + gravação por delta (padrão visual e de interação)
|
||||
|
|
@ -137,3 +137,11 @@ Componentes:
|
|||
- `docs/plans/autosave-tendencia-lista-municipios.md` (**B24**) · `docs/plans/tooltip-celulas-listas.md` (**B23**) · `docs/plans/gerenciar-assessores.md` (**B19 ✓**) · `docs/plans/edicao-rapida-lista-pracas.md` (**B9 ✓**)
|
||||
- AGENTS.md — Campaign auth, naming pt-BR/inglês, `overrideAccess: false`, escrita transacional
|
||||
- `PRODUCT.md` / `DESIGN.md` — princípios 2, 3, 4 e 8; Field Desk · regras `campanha-edit-where-you-see.mdc`, `campanha-action-feedback.mdc`
|
||||
|
||||
## Entregue (2026-07-26)
|
||||
|
||||
Implementado como planejado, sem desvio de escopo. `nextAdvisorIdsAfterMembership` mora agora em `src/lib/municipalityAdvisorMembership.ts` (com unit tests próprios, que faltavam); `setMunicipalityAdvisorMembershipRecord`/`setMunicipalityAdvisorMembership` em `actions/municipality.ts` cobertos por 6 int tests (coordinator/candidate, auto-atribuição, negação de `advisor`/`leader`, idempotência, limite de 10); `POST /campanha/municipios/advisors` espelha `expected-votes/route.ts` byte a byte no contrato. O polish do `/impeccable` alinhou o gatilho ao irmão `MunicipalityListExpectedVotesControl` (`aria-expanded`/`aria-haspopup`/realce ao abrir) — única mudança visual além do que o plano já especificava. `assignMunicipalityAdvisorsFormAction` sobrevive intacto só para `/editar`; `pnpm exec knip` confirma nada órfão (a checagem full falha por um problema pré-existente P3 ao carregar `payload.config.ts`, não relacionado a este item).
|
||||
|
||||
Achado de infraestrutura fora do escopo original: o `webServer` do Playwright (`pnpm dev`) compila uma rota `POST`-only só no primeiro hit, e o Fast Refresh disparado por essa compilação recarrega a página e aborta o fetch em voo do e2e (`ERR_ABORTED`). Corrigido com um prewarm dummy de `/campanha/municipios/advisors` e `/campanha/municipios/expected-votes` em `tests/e2e/setup.e2e.spec.ts`, antes dos specs reais.
|
||||
|
||||
Gate: `tsc --noEmit`, `lint --max-warnings=0`, `format:check`, `check:cycles` limpos; 393 unit+int (6 novos) verdes; `pnpm build` verde; Aikido 0 achados nos 9 arquivos novos/editados. E2E do fluxo B27 verde tanto isolado quanto dentro da suíte completa — rodadas full-suite mostraram falhas rotativas em specs **não relacionados** (checkbox de consentimento, demandas, conceitos) sob carga sustentada da máquina, sempre verdes ao isolar; tratado como flakiness de ambiente pré-existente, não regressão desta entrega.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,56 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { setMunicipalityAdvisorMembership } from '@/app/(campaign)/campanha/actions/municipality'
|
||||
import { MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES } from '@/lib/schemas/municipality'
|
||||
import { positiveRelationshipId } from '@/lib/schemas/primitives'
|
||||
import {
|
||||
campaignJsonMutationErrorResponse,
|
||||
parseCampaignJsonRequestBody,
|
||||
} from '@/utilities/campaignJsonMutationRoute'
|
||||
import { uniqueRelationshipIds } from '@/utilities/relationship'
|
||||
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
|
||||
|
||||
import type { MunicipalityListAdvisorsResponse } from './types'
|
||||
|
||||
export type { MunicipalityListAdvisorsResponse } from './types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const bodySchema = z.object({
|
||||
municipalityId: positiveRelationshipId,
|
||||
advisorId: positiveRelationshipId,
|
||||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
): Promise<NextResponse<MunicipalityListAdvisorsResponse>> {
|
||||
if (!isSameOriginRequest(request)) {
|
||||
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = await parseCampaignJsonRequestBody(request)
|
||||
if (!parsed.ok) return parsed.response
|
||||
|
||||
try {
|
||||
const { municipalityId, advisorId, assigned } = bodySchema.parse(parsed.body)
|
||||
const updated = await setMunicipalityAdvisorMembership({
|
||||
municipality: municipalityId,
|
||||
advisor: advisorId,
|
||||
assigned,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'success',
|
||||
message: assigned ? 'Assessor atribuído.' : 'Assessor removido.',
|
||||
advisors: uniqueRelationshipIds(updated.advisors),
|
||||
})
|
||||
} catch (error) {
|
||||
return campaignJsonMutationErrorResponse(error, {
|
||||
safeMessages: MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES,
|
||||
genericMessage:
|
||||
'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export type MunicipalityListAdvisorsResponse =
|
||||
| {
|
||||
status: 'success'
|
||||
message: string
|
||||
advisors: number[]
|
||||
}
|
||||
| {
|
||||
status: 'error'
|
||||
message: string
|
||||
}
|
||||
|
|
@ -6,9 +6,10 @@ import { setMunicipalityExpectedVotes } from '@/app/(campaign)/campanha/actions/
|
|||
import { positiveRelationshipId } from '@/lib/schemas/primitives'
|
||||
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
|
||||
import {
|
||||
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
|
||||
mapCampaignFormActionError,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
campaignJsonMutationErrorResponse,
|
||||
parseCampaignJsonRequestBody,
|
||||
} from '@/utilities/campaignJsonMutationRoute'
|
||||
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
|
||||
|
||||
import type { MunicipalityListExpectedVotesResponse } from './types'
|
||||
|
||||
|
|
@ -28,16 +29,6 @@ const bodySchema = z.object({
|
|||
}),
|
||||
})
|
||||
|
||||
const isSameOriginRequest = (request: Request): boolean => {
|
||||
const origin = request.headers.get('origin')
|
||||
if (!origin) return true
|
||||
try {
|
||||
return new URL(origin).origin === new URL(request.url).origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
): Promise<NextResponse<MunicipalityListExpectedVotesResponse>> {
|
||||
|
|
@ -45,18 +36,11 @@ export async function POST(
|
|||
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
|
||||
}
|
||||
|
||||
let json: unknown
|
||||
try {
|
||||
json = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', message: 'Corpo da requisição inválido.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const parsed = await parseCampaignJsonRequestBody(request)
|
||||
if (!parsed.ok) return parsed.response
|
||||
|
||||
try {
|
||||
const { municipalityId, expectedVotes } = bodySchema.parse(json)
|
||||
const { municipalityId, expectedVotes } = bodySchema.parse(parsed.body)
|
||||
const updated = await setMunicipalityExpectedVotes({
|
||||
municipality: municipalityId,
|
||||
expectedVotes,
|
||||
|
|
@ -68,28 +52,10 @@ export async function POST(
|
|||
savedExpectedVotes: toVoteEstimateScenarioViewModel(updated.expectedVotes),
|
||||
})
|
||||
} catch (error) {
|
||||
const mapped = mapCampaignFormActionError({
|
||||
error,
|
||||
safeMessages: [
|
||||
...municipalityStaffEditSafeMessages,
|
||||
'Autenticação necessária.',
|
||||
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
|
||||
],
|
||||
return campaignJsonMutationErrorResponse(error, {
|
||||
safeMessages: municipalityStaffEditSafeMessages,
|
||||
genericMessage:
|
||||
'Não foi possível salvar os votos estimados. Verifique seu acesso e tente novamente.',
|
||||
})
|
||||
|
||||
const isAuthError =
|
||||
error instanceof Error &&
|
||||
(error.message === 'Autenticação necessária.' ||
|
||||
error.message === CAMPAIGN_SESSION_EXPIRED_MESSAGE)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: mapped.message ?? 'Não foi possível salvar os votos estimados.',
|
||||
},
|
||||
{ status: isAuthError ? 401 : 400 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -83,7 +83,6 @@ export type MunicipalityListProps = {
|
|||
advisorOptions: EligibleAdvisorOption[]
|
||||
columnFilterOptions: MunicipalityColumnFilterOptions
|
||||
trendFormAction: MunicipalityStaffFormAction
|
||||
advisorsFormAction: MunicipalityStaffFormAction
|
||||
signalFormAction: MunicipalityStaffFormAction
|
||||
state: MunicipalityListState
|
||||
}
|
||||
|
|
@ -248,7 +247,6 @@ const municipalityListColumns = ({
|
|||
advisorNamesById,
|
||||
advisorOptions,
|
||||
trendFormAction,
|
||||
advisorsFormAction,
|
||||
signalFormAction,
|
||||
}: MunicipalityListProps): Array<CampaignTableColumn<MunicipalityListViewModel>> => [
|
||||
{
|
||||
|
|
@ -372,12 +370,10 @@ const municipalityListColumns = ({
|
|||
isCoordinator ? (
|
||||
<MunicipalityListAdvisorsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
currentAdvisorIDs={municipality.advisorIDs}
|
||||
isPriority={municipality.priority === 'alta'}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
formAction={advisorsFormAction}
|
||||
/>
|
||||
) : (
|
||||
<MunicipalityAdvisorAvatarStack
|
||||
|
|
@ -600,12 +596,10 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
|
|||
{isCoordinator ? (
|
||||
<MunicipalityListAdvisorsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
currentAdvisorIDs={municipality.advisorIDs}
|
||||
isPriority={isPriority}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
formAction={props.advisorsFormAction}
|
||||
/>
|
||||
) : names.length ? (
|
||||
names.join(', ')
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import { useActionState, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import type { MunicipalityListAdvisorsResponse } from '@/app/(campaign)/campanha/(app)/municipios/advisors/types'
|
||||
import {
|
||||
advisorEntriesFromIds,
|
||||
formatAdvisorNamesTooltip,
|
||||
|
|
@ -10,58 +11,170 @@ import {
|
|||
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
|
||||
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { matchesAtWordStart } from '@/lib/wordStartFilter'
|
||||
import type {
|
||||
EligibleAdvisorOption,
|
||||
MunicipalityAdvisorSummary,
|
||||
} from '@/utilities/municipalityViewModels'
|
||||
|
||||
const ADVISORS_ENDPOINT = '/campanha/municipios/advisors'
|
||||
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar os assessores. Tente novamente.'
|
||||
|
||||
const sameIdSet = (left: readonly number[], right: readonly number[]): boolean => {
|
||||
if (left.length !== right.length) return false
|
||||
const rightSet = new Set(right)
|
||||
return left.every((id) => rightSet.has(id))
|
||||
}
|
||||
|
||||
type MunicipalityListAdvisorsControlProps = {
|
||||
municipalityID: number
|
||||
municipalitySlug: string
|
||||
currentAdvisorIDs: number[]
|
||||
/** Raises the empty state to "Sem responsável" — see `MissingAdvisorBadge`. */
|
||||
isPriority: boolean
|
||||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
|
||||
options: EligibleAdvisorOption[]
|
||||
formAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
}
|
||||
|
||||
export const MunicipalityListAdvisorsControl = ({
|
||||
municipalityID,
|
||||
municipalitySlug,
|
||||
currentAdvisorIDs,
|
||||
isPriority,
|
||||
advisorNamesById,
|
||||
options,
|
||||
formAction,
|
||||
}: MunicipalityListAdvisorsControlProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [state, submitAction, isPending] = useActionState(formAction, {})
|
||||
const currentSet = new Set(currentAdvisorIDs)
|
||||
|
||||
const names = advisorEntriesFromIds(currentAdvisorIDs, advisorNamesById)
|
||||
const tooltipContent = formatAdvisorNamesTooltip(names)
|
||||
const [selectedIDs, setSelectedIDs] = useState<number[]>(currentAdvisorIDs)
|
||||
const [query, setQuery] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const pendingCountRef = useRef(0)
|
||||
const lastPropsIDsRef = useRef(currentAdvisorIDs)
|
||||
// Requests can settle out of send order (network jitter), so the send order
|
||||
// is tracked explicitly: only the response with the highest sequence number
|
||||
// seen so far is kept, and it is adopted — win or fail — once every
|
||||
// in-flight delta has settled (`pendingCountRef` back to 0). No response
|
||||
// ever writes `lastPropsIDsRef`: that ref exists solely to tell a genuine
|
||||
// external prop change (nav/refresh) apart from a no-op re-render carrying
|
||||
// the same pre-edit content, and advancing it here would make a later
|
||||
// stale re-render of that same pre-edit content look "external" and
|
||||
// clobber the optimistic state back to it.
|
||||
const requestSeqRef = useRef(0)
|
||||
const latestConfirmedRef = useRef<{ seq: number; advisors: number[] } | null>(null)
|
||||
|
||||
// Adopt server props only when they change from outside (navigation / RSC
|
||||
// refresh) — an in-flight delta's optimistic state must not be clobbered by
|
||||
// a prop identical in content to what we already had.
|
||||
useEffect(() => {
|
||||
if (state.status !== 'success') return
|
||||
toast.success(state.message)
|
||||
setOpen(false)
|
||||
}, [state.message, state.status])
|
||||
if (sameIdSet(currentAdvisorIDs, lastPropsIDsRef.current)) return
|
||||
lastPropsIDsRef.current = currentAdvisorIDs
|
||||
setSelectedIDs(currentAdvisorIDs)
|
||||
}, [currentAdvisorIDs])
|
||||
|
||||
// Every eligible account (coordinator/advisor/candidate) is listed here
|
||||
// regardless of current assignment, so it doubles as the name lookup for an
|
||||
// id the popover just optimistically added — no round trip needed to label
|
||||
// it. `advisorNamesById` is the fallback for an id that fell out of
|
||||
// eligibility but is still assigned; `advisorEntriesFromIds` drops the rare
|
||||
// id found in neither, same as every other advisor cell in the list.
|
||||
const advisorLookup = useMemo(() => {
|
||||
const lookup = new Map<number, { id: number; name: string }>(advisorNamesById)
|
||||
for (const option of options) lookup.set(option.id, { id: option.id, name: option.name })
|
||||
return lookup
|
||||
}, [advisorNamesById, options])
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs])
|
||||
const chips = useMemo(
|
||||
() =>
|
||||
[...advisorEntriesFromIds(selectedIDs, advisorLookup)].sort((left, right) =>
|
||||
left.name.localeCompare(right.name, 'pt-BR'),
|
||||
),
|
||||
[selectedIDs, advisorLookup],
|
||||
)
|
||||
|
||||
// B23's hover/focus tooltip stays on the trigger even though it repeats the
|
||||
// chips: it's the no-open-popover read. `null` for an empty list — the
|
||||
// trigger already reads "Sem responsável"/"Sem assessor" by extenso.
|
||||
const tooltipContent = formatAdvisorNamesTooltip(chips)
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
() => options.filter((option) => matchesAtWordStart(option.name, query)),
|
||||
[options, query],
|
||||
)
|
||||
|
||||
const toggle = (advisorId: number, assigned: boolean) => {
|
||||
setErrorMessage(null)
|
||||
setSelectedIDs((current) => {
|
||||
const next = new Set(current)
|
||||
if (assigned) next.add(advisorId)
|
||||
else next.delete(advisorId)
|
||||
return [...next]
|
||||
})
|
||||
setQuery('')
|
||||
|
||||
pendingCountRef.current += 1
|
||||
setIsPending(true)
|
||||
const requestSeq = (requestSeqRef.current += 1)
|
||||
|
||||
void (async () => {
|
||||
const revertDelta = () => {
|
||||
setSelectedIDs((current) => {
|
||||
const next = new Set(current)
|
||||
if (assigned) next.delete(advisorId)
|
||||
else next.add(advisorId)
|
||||
return [...next]
|
||||
})
|
||||
}
|
||||
|
||||
// Single settle point for every exit path (success, mapped error,
|
||||
// network failure): decrements the pending count and, once it reaches
|
||||
// 0, stops the spinner and reconciles to the latest confirmed server
|
||||
// set — even on this request's own failure, an earlier delta in the
|
||||
// same batch may have already confirmed one.
|
||||
const finishRequest = () => {
|
||||
pendingCountRef.current = Math.max(0, pendingCountRef.current - 1)
|
||||
if (pendingCountRef.current > 0) return
|
||||
setIsPending(false)
|
||||
if (latestConfirmedRef.current) setSelectedIDs(latestConfirmedRef.current.advisors)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(ADVISORS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ municipalityId: municipalityID, advisorId, assigned }),
|
||||
})
|
||||
|
||||
const payload = (await response.json()) as MunicipalityListAdvisorsResponse
|
||||
|
||||
if (!response.ok || payload.status !== 'success') {
|
||||
revertDelta()
|
||||
setErrorMessage(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
|
||||
finishRequest()
|
||||
return
|
||||
}
|
||||
|
||||
if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) {
|
||||
latestConfirmedRef.current = { seq: requestSeq, advisors: payload.advisors }
|
||||
}
|
||||
finishRequest()
|
||||
} catch {
|
||||
revertDelta()
|
||||
setErrorMessage(SAVE_ERROR_MESSAGE)
|
||||
finishRequest()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const statusMessage = errorMessage ? errorMessage : isPending ? 'Salvando assessores.' : ''
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{/* No advisors: the button already reads "Sem responsável"/"Sem assessor"
|
||||
by extenso, so a tooltip would only echo it (`tooltipContent` is
|
||||
null — see `CampaignHoverTooltip` for the no-op contract). */}
|
||||
<CampaignHoverTooltip
|
||||
content={tooltipContent}
|
||||
align="start"
|
||||
|
|
@ -71,51 +184,93 @@ export const MunicipalityListAdvisorsControl = ({
|
|||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
className={cn(
|
||||
'min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
open ? 'bg-muted/60' : undefined,
|
||||
)}
|
||||
aria-label="Editar assessores"
|
||||
>
|
||||
<MunicipalityAdvisorAvatarStack advisors={names} isPriority={isPriority} />
|
||||
<MunicipalityAdvisorAvatarStack advisors={chips} isPriority={isPriority} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</CampaignHoverTooltip>
|
||||
<PopoverContent align="start" className="w-80">
|
||||
<form action={submitAction} className="flex flex-col gap-3">
|
||||
<input type="hidden" name="municipalityId" value={municipalityID} />
|
||||
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<PopoverContent align="start" className="w-80 p-0">
|
||||
<div className="relative flex flex-col gap-2 p-3 pb-0">
|
||||
<p className="text-sm font-medium">Atribuir assessores</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
O assessor vê e gerencia somente os municípios que administra.
|
||||
</p>
|
||||
<ul className="flex max-h-60 flex-col gap-1 overflow-y-auto">
|
||||
{options.map((option) => (
|
||||
<li key={option.id}>
|
||||
<Label
|
||||
htmlFor={`municipality-list-advisor-${municipalityID}-${option.id}`}
|
||||
className="flex min-h-11 items-center gap-3 rounded-md px-2 hover:bg-accent"
|
||||
>
|
||||
<Checkbox
|
||||
id={`municipality-list-advisor-${municipalityID}-${option.id}`}
|
||||
name="advisors"
|
||||
value={String(option.id)}
|
||||
defaultChecked={currentSet.has(option.id)}
|
||||
/>
|
||||
<span>
|
||||
{option.name}
|
||||
{option.isCurrent ? ' (você)' : ''}
|
||||
</span>
|
||||
</Label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{state.message && state.status !== 'success' ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
className="absolute top-3 right-3 size-3.5 text-muted-foreground"
|
||||
aria-label="Salvando assessores"
|
||||
/>
|
||||
) : null}
|
||||
<Button type="submit" disabled={isPending} className="min-h-11 w-full">
|
||||
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
Salvar assessores
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{chips.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 px-3 pt-2">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
aria-label={`Remover ${chip.name}`}
|
||||
onClick={() => toggle(chip.id, false)}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
|
||||
>
|
||||
<span className="truncate">{chip.name}</span>
|
||||
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Command shouldFilter={false} className="mt-1">
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Buscar assessor…"
|
||||
aria-label="Buscar assessor"
|
||||
/>
|
||||
<CommandList>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum resultado.
|
||||
</p>
|
||||
) : (
|
||||
filteredOptions.map((option) => {
|
||||
const assigned = selectedSet.has(option.id)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`advisor-${option.id}`}
|
||||
data-checked={assigned}
|
||||
onSelect={() => toggle(option.id, !assigned)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{option.name}
|
||||
{option.isCurrent ? ' (você)' : ''}
|
||||
</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
{errorMessage ? (
|
||||
<div className="p-3 pt-2">
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{statusMessage}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
|
|
|||
27
src/lib/municipalityAdvisorMembership.ts
Normal file
27
src/lib/municipalityAdvisorMembership.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality'
|
||||
|
||||
/**
|
||||
* Returns the next advisor-id list after applying one membership change, or
|
||||
* `null` when the municipality is already in the desired state (no write
|
||||
* needed). Shared by the advisor-carteira mutations (`actions/advisor.ts`)
|
||||
* and the município-side quick edit (`actions/municipality.ts`, B27) — the
|
||||
* two sides of the same `municipality.advisors` relation.
|
||||
*/
|
||||
export const nextAdvisorIdsAfterMembership = (
|
||||
currentAdvisorIDs: readonly number[],
|
||||
advisorId: number,
|
||||
assigned: boolean,
|
||||
): number[] | null => {
|
||||
const alreadyAssigned = currentAdvisorIDs.includes(advisorId)
|
||||
if (assigned === alreadyAssigned) return null
|
||||
|
||||
if (assigned) {
|
||||
if (currentAdvisorIDs.length >= MAX_ADVISORS_PER_MUNICIPALITY) {
|
||||
throw new Error(
|
||||
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
|
||||
)
|
||||
}
|
||||
return [...currentAdvisorIDs, advisorId]
|
||||
}
|
||||
return currentAdvisorIDs.filter((id) => id !== advisorId)
|
||||
}
|
||||
|
|
@ -47,6 +47,21 @@ export const municipalityAdvisorsAssignmentSchema = z.object({
|
|||
.transform((ids) => [...new Set(ids)]),
|
||||
})
|
||||
|
||||
/** Single-toggle delta from the list popover — one advisor at a time, never the whole array. */
|
||||
export const municipalityAdvisorMembershipSchema = z.object({
|
||||
municipality: positiveRelationshipId,
|
||||
advisor: positiveRelationshipId,
|
||||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
export const MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE =
|
||||
'Somente a coordenação geral ou o candidato designa assessores.'
|
||||
|
||||
export const MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES = [
|
||||
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
|
||||
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
|
||||
] as const
|
||||
|
||||
export const municipalityExpectedVotesSchema = z.object({
|
||||
municipality: positiveRelationshipId,
|
||||
expectedVotes: voteEstimateScenarioFieldsSchema,
|
||||
|
|
@ -57,4 +72,5 @@ export type MunicipalityPoliticalTrendInput = z.input<typeof municipalityPolitic
|
|||
export type MunicipalityAdvisorsAssignmentInput = z.input<
|
||||
typeof municipalityAdvisorsAssignmentSchema
|
||||
>
|
||||
export type MunicipalityAdvisorMembershipInput = z.input<typeof municipalityAdvisorMembershipSchema>
|
||||
export type MunicipalityExpectedVotesInput = z.input<typeof municipalityExpectedVotesSchema>
|
||||
|
|
|
|||
57
src/utilities/campaignJsonMutationRoute.ts
Normal file
57
src/utilities/campaignJsonMutationRoute.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
import {
|
||||
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
|
||||
mapCampaignFormActionError,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
|
||||
export type CampaignJsonErrorBody = { status: 'error'; message: string }
|
||||
|
||||
/**
|
||||
* Shared shell for `POST /campanha/**` JSON mutation routes (list-popover
|
||||
* auto-save): parses the body, mapping a malformed one to the same 400 both
|
||||
* routes already used. Domain parsing/success payloads stay per-route.
|
||||
*/
|
||||
export const parseCampaignJsonRequestBody = async (
|
||||
request: Request,
|
||||
): Promise<
|
||||
{ ok: true; body: unknown } | { ok: false; response: NextResponse<CampaignJsonErrorBody> }
|
||||
> => {
|
||||
try {
|
||||
return { ok: true, body: await request.json() }
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ status: 'error', message: 'Corpo da requisição inválido.' },
|
||||
{ status: 400 },
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a thrown error to the JSON error response: safe messages (incl. the
|
||||
* two auth ones, appended once here) pass through as 400, an expired/missing
|
||||
* session is 401, anything else collapses to `genericMessage`.
|
||||
*/
|
||||
export const campaignJsonMutationErrorResponse = (
|
||||
error: unknown,
|
||||
{ safeMessages, genericMessage }: { safeMessages: readonly string[]; genericMessage: string },
|
||||
): NextResponse<CampaignJsonErrorBody> => {
|
||||
const mapped = mapCampaignFormActionError({
|
||||
error,
|
||||
safeMessages: [...safeMessages, 'Autenticação necessária.', CAMPAIGN_SESSION_EXPIRED_MESSAGE],
|
||||
genericMessage,
|
||||
})
|
||||
|
||||
const isAuthError =
|
||||
error instanceof Error &&
|
||||
(error.message === 'Autenticação necessária.' ||
|
||||
error.message === CAMPAIGN_SESSION_EXPIRED_MESSAGE)
|
||||
|
||||
return NextResponse.json(
|
||||
{ status: 'error', message: mapped.message ?? genericMessage },
|
||||
{ status: isAuthError ? 401 : 400 },
|
||||
)
|
||||
}
|
||||
|
|
@ -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)),
|
||||
]
|
||||
|
|
|
|||
15
src/utilities/sameOriginRequest.ts
Normal file
15
src/utilities/sameOriginRequest.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* CSRF guard for cookie-authenticated JSON route handlers (auto-save popovers):
|
||||
* a same-origin browser request either omits `Origin` (same-site navigation)
|
||||
* or sends one matching the request URL; anything else is rejected before the
|
||||
* body is even parsed. Shared by every `POST /campanha/**` JSON endpoint.
|
||||
*/
|
||||
export const isSameOriginRequest = (request: Request): boolean => {
|
||||
const origin = request.headers.get('origin')
|
||||
if (!origin) return true
|
||||
try {
|
||||
return new URL(origin).origin === new URL(request.url).origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
151
tests/int/campaignMunicipalityAdvisorMembership.int.spec.ts
Normal file
151
tests/int/campaignMunicipalityAdvisorMembership.int.spec.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { setMunicipalityAdvisorMembershipRecord } from '@/app/(campaign)/campanha/actions/municipality'
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
describe('setMunicipalityAdvisorMembershipRecord (B27)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it.each(['coordinator', 'candidate'] as const)(
|
||||
'lets a %s assign and remove an advisor by delta',
|
||||
async (role) => {
|
||||
const fixtures = campaignFixtures()
|
||||
const actor = await fixtures.createCampaignUser(role)
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
const assigned = await setMunicipalityAdvisorMembershipRecord(payload, actor, {
|
||||
municipality: municipality.id,
|
||||
advisor: advisor.id,
|
||||
assigned: true,
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
expect(
|
||||
assigned.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
|
||||
).toContain(advisor.id)
|
||||
|
||||
const removed = await setMunicipalityAdvisorMembershipRecord(payload, actor, {
|
||||
municipality: municipality.id,
|
||||
advisor: advisor.id,
|
||||
assigned: false,
|
||||
})
|
||||
expect(
|
||||
removed.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)) ?? [],
|
||||
).not.toContain(advisor.id)
|
||||
},
|
||||
)
|
||||
|
||||
it('lets a coordinator assign themself or the candidate — the path assertTargetAdvisor would block', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const candidate = await fixtures.createCampaignUser('candidate')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
const selfAssigned = await setMunicipalityAdvisorMembershipRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
advisor: coordinator.id,
|
||||
assigned: true,
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
expect(
|
||||
selfAssigned.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
|
||||
).toContain(coordinator.id)
|
||||
|
||||
const candidateAssigned = await setMunicipalityAdvisorMembershipRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
advisor: candidate.id,
|
||||
assigned: true,
|
||||
})
|
||||
expect(
|
||||
candidateAssigned.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
|
||||
).toContain(candidate.id)
|
||||
})
|
||||
|
||||
it('denies advisor and leader actors', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisorActor = await fixtures.createCampaignUser('advisor')
|
||||
const leaderActor = await fixtures.createCampaignUser('leader')
|
||||
const target = await fixtures.createCampaignUser('advisor')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
await expect(
|
||||
setMunicipalityAdvisorMembershipRecord(payload, advisorActor, {
|
||||
municipality: municipality.id,
|
||||
advisor: target.id,
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow(/coordenação geral ou o candidato/i)
|
||||
|
||||
await expect(
|
||||
setMunicipalityAdvisorMembershipRecord(payload, leaderActor, {
|
||||
municipality: municipality.id,
|
||||
advisor: target.id,
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow(/coordenação geral ou o candidato/i)
|
||||
})
|
||||
|
||||
it('is idempotent (no-op returns the same document without re-writing)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
await setMunicipalityAdvisorMembershipRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
advisor: advisor.id,
|
||||
assigned: true,
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
const again = await setMunicipalityAdvisorMembershipRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
advisor: advisor.id,
|
||||
assigned: true,
|
||||
})
|
||||
expect(again.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id))).toEqual([
|
||||
advisor.id,
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects once the municipality is at the 10-advisor cap', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
await setMunicipalityAdvisorMembershipRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
advisor: advisor.id,
|
||||
assigned: true,
|
||||
})
|
||||
}
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
const eleventh = await fixtures.createCampaignUser('advisor')
|
||||
await expect(
|
||||
setMunicipalityAdvisorMembershipRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
advisor: eleventh.id,
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow(/no máximo 10 assessores/i)
|
||||
})
|
||||
})
|
||||
|
|
@ -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"')
|
||||
|
|
|
|||
26
tests/unit/municipalityAdvisorMembership.unit.spec.ts
Normal file
26
tests/unit/municipalityAdvisorMembership.unit.spec.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership'
|
||||
import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality'
|
||||
|
||||
describe('nextAdvisorIdsAfterMembership', () => {
|
||||
it('adds an advisor not yet present', () => {
|
||||
expect(nextAdvisorIdsAfterMembership([1, 2], 3, true)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('removes an advisor present in the list', () => {
|
||||
expect(nextAdvisorIdsAfterMembership([1, 2, 3], 2, false)).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('is a no-op (returns null) when already in the desired state', () => {
|
||||
expect(nextAdvisorIdsAfterMembership([1, 2], 2, true)).toBeNull()
|
||||
expect(nextAdvisorIdsAfterMembership([1, 2], 3, false)).toBeNull()
|
||||
})
|
||||
|
||||
it('throws once the municipality is at the advisor cap', () => {
|
||||
const atCap = Array.from({ length: MAX_ADVISORS_PER_MUNICIPALITY }, (_, index) => index + 1)
|
||||
expect(() => nextAdvisorIdsAfterMembership(atCap, 999, true)).toThrow(
|
||||
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue