Merge branch 'cursor/5176346d'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-24 19:27:35 -03:00
commit 86ae32ce98
44 changed files with 13576 additions and 151 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **C12 Registro-fundação entregue em código (2026-07-24):** trajetória de `votePledge` por versions nativas com baseline; atualização municipal `sinal` tipada (explicações, fonte e triangulação); `actionPlan.origin`; demandas vinculadas ao plano com custo derivado e criação múltipla atômica no formulário; `allocationDecision` ex-ante imutável com snapshot pequeno. Migration consolidada `20260724_180000_add_campaign_foundation_records`; testes de integração e jornada E2E dedicados.
- **B18 registrado (2026-07-24):** filtros **salvos com nome** em `/campanha/municipios` (`localStorage`) + acesso rápido de 2º nível sob Municípios no sidebar (hover desktop / expand mobile; sticky só no filtro salvo ativo). Soft B16/B17 (barra). Fatia concreta do “saved views” que FD2 adiava como genérico. Sem migration. Plano: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md).
- **Avaliação: reorder de colunas por DnD (2026-07-24):** pedido para arrastar headers em `/campanha/municipios` → **fora de escopo** (não confundir com **B17** hide/show). Tecnicamente possível (~1–1,5d), mas conflita com sort B15 + filtros B16 no mesmo header, anti spreadsheet do PRODUCT, e não desbloqueia decisão de alocação. Gatilho em [`reordenar-colunas-lista-municipios.md`](../../../docs/plans/reordenar-colunas-lista-municipios.md).
- **B17 registrado (2026-07-24):** seletor **mostrar/ocultar** colunas em `/campanha/municipios` (desktop) — Popover + `localStorage`; `name` obrigatória; soft B15/B16; prepara viewport para E9. Distinto do reorder DnD (fora de escopo). Sem migration. Plano: [`seletor-colunas-lista-municipios.md`](../../../docs/plans/seletor-colunas-lista-municipios.md).
@ -84,6 +85,7 @@ alwaysApply: false
## Decisões
- Em 2026-07-24, **header da coluna A11 = `2022` (não "Concentração 2022"):** o título longo competia com células curtas (`%` + `12º`); no contexto da mesa o ano basta. Definição da métrica fica no hover do header (`formatMunicipalityConcentrationHint`). **Rejeitado:** manter "Concentração" no rótulo visível.
- Em 2026-07-24, **C12 preserva fatos e decisões antes de sugerir:** pledges mantêm histórico nativo; sinais exigem tipo+fonte e distinguem triangulação; origem/custo são observados via plano→demandas; decisão de alocação é append-only para campanha (correção só por admin), sempre com racional e snapshot ex-ante pequeno. Demandas criadas junto do plano compartilham a mesma transação e fazem rollback integral em conflito. **Rejeitado:** sobrescrever trajetória, custo duplicado no plano, sugestão que decide automaticamente.
- Em 2026-07-24, **uma única série por cenário — "Metas" eliminadas:** o grupo `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") duplicava o conceito de votos estimados e foi removido do app (collection, zod, view models, form/card, `utilities/voteGoals.ts`); a série do município é `expectedVotes` (pessimista/média/otimista). Planilha EXPECTATIVA mapeia **Bom→otimista, Regular→média, Mínimo→pessimista**. Migration `20260724_133600_drop_municipality_vote_goals` com backfill metas→estimativas (estimativa existente vence). **Supersede** o "não fundir com `voteGoals`" da decisão A10 abaixo. **Rejeitado:** manter os dois grupos com semânticas distintas (meta de planejamento × estimativa) — a mesa trabalha com um número por cenário.
- Em 2026-07-24, **E4R overwrite-always:** `pnpm db:seed:projecao` sempre sobrescreve `expectedVotes` + `priority` nas linhas casadas (planilha nova sobre DB já populado). **Rejeitado:** empty-only / `--overwrite` opt-in (quebraria o re-seed). Dry-run continua obrigatório antes de produção.
- Em 2026-07-23, **access explícito em toda collection (hardening Fase 0):** o default do Payload ("qualquer autenticado pode CRUD") é tratado como bug; toda collection declara `access` explícito — `users`/`signature`/`subscription`/`consent` só Payload admin (`payloadAdminOnly`), escrita de CMS admin-only com leitura pública preservada. JWTs de `campaignUser` não operam mais essas collections via `/api`. **Rejeitado:** confiar no default do Payload; RBAC só na camada de UI.

View file

@ -1,8 +1,10 @@
# Escala e DRY pós-C7 (planos de ação / agenda)
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-19
Atualizado em: 2026-07-24 (extensão pós-C12 absorvida)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C11)
Appetite da extensão pós-C12: ~0,5–1 dia eng, sem migration
Impeccable: B — ajustes encaixados nos forms/detalhe existentes
Responsável: —
## Contexto
@ -22,6 +24,8 @@ Duas passagens `/simplify` (pré- e pós-rebase com `main`/C8–C9) marcaram com
9. **Lista de coordenadores sem paginação no form.** `getEligibleNucleusCoordinatorOptions` com `pagination: false` em novo/editar — O(staff) por page view (aceitável no MVP, débito se o time crescer).
10. **`mutationKind` sem tipo compartilhado.** Magic strings `'taskToggle' | 'appendUpdate'` em actions e `ActionPlan.ts` — risco de drift.
11. **DRY menor:** `searchContactComboboxOptions` ainda inline em `contactSearchActions.ts` (liderança já em `utilities/`); label de liderança duplicada entre `actionPlanLeadershipOptions` e `toActionPlanFormViewModel`; `firstValue` local em `actionPlanDetailTabUi.ts` (já existe em `campaignListUrl.ts`); loader combinado da lista `/campanha/planos` (precedente: `loadSupportersPageData` no C9).
12. **Opções de plano sem limite na nova demanda (C12).** `loadActionPlanOptions` usa `pagination: false` e serializa todos os planos acessíveis no `DemandForm`; custo de RSC/hidratação/DOM cresce com a agenda.
13. **Demandas vinculadas sem paginação no detalhe do plano (C12).** A visão geral carrega e renderiza todas as demandas para somar custo e listar vínculos; aceitável no volume atual, mas sem teto.
**Explicitamente fora (revisores pediram skip no simplify / já coberto em outro item):**
@ -31,12 +35,16 @@ Duas passagens `/simplify` (pré- e pós-rebase com `main`/C8–C9) marcaram com
- **Renomear `NucleusTerritoryFields.tsx` → `CampaignTerritoryFields.tsx`** — fricção de descoberta; fill-in, não bloqueia escala.
- **View-model dedup form ↔ detalhe** — refactor grande; só se surgir terceiro consumidor.
- **Testes E2E da vertical** — fora do escopo deste item de escala/DRY.
- **Serialização de `demandsJson` a cada tecla** — máximo de 20 drafts torna o custo limitado; reavaliar somente se houver input lag medido.
- **Bulk de demandas criadas com o plano** — manter Local API sequencial para preservar hooks/access/slug; reavaliar se p95 da transação passar de 500 ms.
- **Helper compartilhado create/update para drafts** — só extrair quando surgir o 3º call site; dois loops curtos não justificam uma abstração.
## Objetivos
- Agenda (`/campanha/planos`) permanece responsiva com dezenas de atualizações e tarefas por plano — medir antes de migration; feed append-only só se o volume justificar.
- Detalhe e edição seguem o mesmo padrão de loaders que núcleos (orquestrador + tab data).
- Hot paths de mutação (toggle tarefa, append update) minimizam bytes lidos/escritos sem quebrar locks transacionais nem RBAC.
- Seletores/listas do vínculo plano↔demanda permanecem limitados em payload e DOM quando a agenda crescer.
- Guardrails: `overrideAccess: false` com `user`; sem novo `Consent`; migrations só na Fase 1 condicional (`actionPlanUpdate`).
## Decisões travadas
@ -47,6 +55,7 @@ Duas passagens `/simplify` (pré- e pós-rebase com `main`/C8–C9) marcaram com
- **Fase 1 condicional respeita congelamento ~20/09** — se entrar perto da reta final, adiar para pós-eleição; Fases 2–4 não exigem migration.
- **Cortável** se a agenda permanecer pequena (poucos planos, feeds curtos). Fase 1 é a mais cortável (e a única com migration). Fases 2–3 são as mais valiosas se houver uso real da agenda antes de 16/08.
- **i18n e naming** (AGENTS.md): identificadores em inglês (`loadActionPlanDetailPageData`, `loadCampaignUserNamesById`, `ACTION_PLAN_MUTATION_KIND`), strings visíveis em pt-BR inalteradas.
- **Fase 6 pós-C12 é condicional e sem schema.** Opções: manter listas integrais | picker remoto + paginação | data-grid. **Recomendação:** picker remoto filtrado por município e lista paginada/limitada no card, só quando houver volume real; **rejeitado:** data-grid ou bulk SQL que contorne hooks.
## Questões em aberto
@ -67,6 +76,7 @@ flowchart TD
C7 --> F1
F1 -.condicional volume.-> F5["Fase 5 — Loader lista planos"]
F3 -.opcional.-> F5
C12["C12 Registro-fundação ✓"] --> F6["Fase 6 — Plano ↔ demandas<br/>picker + paginação condicional"]
```
### Fase 1 — Escala do feed de atualizações _(condicional)_
@ -100,11 +110,30 @@ flowchart TD
- `loadActionPlansPageData` combinando canonical URL + list (precedente `loadSupportersPageData`).
- Entra só se a lista ganhar filtros/overview pesados; cortável.
### Fase 6 (condicional) — Escala do vínculo plano ↔ demandas
- Trocar `loadActionPlanOptions(... pagination: false)` por picker server-backed após o município ser escolhido; consulta seleciona somente `id`, `title` e `municipality`.
- Paginar ou limitar o card “Demandas vinculadas” no detalhe do plano, preservando a soma de custo em aggregate separado e link para a lista filtrada.
- Gatilhos: ≥100 planos acessíveis a um ator, ≥50 demandas num plano, payload RSC >100 KB ou latência p95 >500 ms.
- Sem migration; reusar `AsyncSearchCombobox`, access existente e o shell de paginação. Não criar segundo sistema de picker/lista.
## Já resolvido no simplify/critique (não reabrir)
- Snapshot JSON da migration C12 gerado e verificado sem schema drift.
- Schema dos drafts derivado de `campaignDemandCreateSchema`; opções de sinal derivadas dos enums canônicos.
- Casts/estado/nullabilidade redundantes removidos; query params e access reutilizam helpers/política existentes.
- Município do plano cacheado em `req.context` durante creates aninhados, removendo leituras repetidas na mesma transação.
## Dados → decisão → apresentação
Dados: N/A — esta extensão reduz payload, I/O e DOM; não cria métrica nem nova apresentação analítica.
**Migration:** somente Fase 1b (condicional). Demais fases sem schema.
## Dependências
- **Dura:** C7 Escala e DRY pós-C3 — entregue (merge em `main` pendente do PR atual).
- **Dura:** C7 Escala e DRY pós-C3 — entregue.
- **Dura da Fase 6:** C12 Registro-fundação — entregue em código.
- **Suave:** C8 `pg_trgm` em `contact` — já em `main`; acelera typeahead se Fase 4 adotar o padrão.
- **Suave:** C10 `errorProps` — paralelo; não bloqueia C11 (escopos distintos).
- Reuso: `src/utilities/actionPlanPageData.ts`, `actionPlanDetailPageData.ts`, `actionPlanViewModels.ts`, `src/app/(campaign)/campanha/actions/actionPlan.ts`, `src/collections/ActionPlan.ts`, padrão `loadNucleusDetailPageData`, `campaignListUrl.ts`, `contactSearchQuery.ts`.

View file

@ -23,7 +23,7 @@ Auditoria relatório × schema atual (2026-07-21). O que **já existe e cobre**:
| --- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
| G1 | **Trajetória de pledge** ("o que sabíamos em 15/08") — valor atual sobrescreve o anterior | `votePledge` guarda só o corrente | `versions: true` (sem drafts) em `votePledge` + leitura da série; **nunca sobrescrever** vira garantia de plataforma | C12 |
| G2 | **Sinal qualitativo de rede tipado** (invasão, esfriamento, visita de adversário, proposta a broker; fonte; triangulação) | `municipalityUpdate.kind` = semanal/urgente/nota, texto livre | Novo kind `sinal` + campos `signalType`, `signalSource`, `triangulated` (staff-only) | C12 |
| G3 | **Esforço/agenda por município** (o lado privado que evapora: dias de candidato, origem da visita, recurso) | `actionPlan` tem evento+status; sem origem nem custo | `actionPlan.origin` (`dado \| pedido_broker \| obrigacao_politica` — J-B) + `resourceCost` staff-only opcional | C12 |
| G3 | **Esforço/agenda por município** (o lado privado que evapora: dias de candidato, origem da visita, recurso) | `actionPlan` tem evento+status; `campaignDemand` já relaciona plano e custo | `actionPlan.origin` (`dado \| pedido_broker \| obrigacao_politica` — J-B) + custo derivado das `campaignDemand` vinculadas | C12 |
| G4 | **Decisão registrada ex-ante** (sugestão dada, aceita/descartada, leitura alternativa, foto da classificação no momento) | Nada | Nova collection `allocationDecision` (municipality, patternId, suggestion, decision `aceita\|descartada\|adiada`, alternativeReading, snapshot, decidedBy/At) — também registra mudanças de nível N0–N4 | C12 |
| G5 | **Meta estadual e decomposição derivada** (QE projetado, faixa da cadeira, potencial por município) | `municipality.expectedVotes` manual, sem conta de cima para baixo nem cobertura | Global `campaignGoals` (meta estadual, margem, ano-base) + utilities de potencial (válidos projetados, captura, headroom) e cobertura Σpledges÷meta | E8 |
| G6 | **Definição de "campo" por ano** (share intracampo, teto do campo) | `electionCandidate.party` existe; não há lista de partidos do campo por eleição | Lib estática curada `src/lib/campoParties.ts` (ano → siglas/números) — dado de configuração, não collection | E8 |
@ -53,7 +53,7 @@ Regras transversais (de §6.2/§6.4, viram comportamento do produto): **barato-a
| --- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ------ | -------- | --------- |
| E8 | **[Conta da cadeira](conta-da-cadeira.md)** | Global `campaignGoals`; potencial por município (válidos projetados, captura do campo, share intracampo via `campoParties.ts`, roll-off, headroom); decomposição meta estadual→município com sanity por TI; cobertura Σ(estimativa do cenário — default média via **A10**)÷meta no dashboard/lista; denominadores sempre válidos projetados (I-B) | O-A | B | ~2d | 2 |
| E9 | **[Fila de alocação](fila-de-alocacao.md)** | Lista de decisão com as 7 colunas (§5.2 do relatório: votos em jogo, meta/cobertura/delta, classe, LQ/captura, rede+frescor, competição, tendência) + **% da própria votação** (âncora da mesa, via A11); ordenação default por déficit descoberto (default do staff quando houver metas); ordenação de risco (defesa por frescor); "coluna da vergonha" | O-B, O-F | B | ~1,5d | 2 |
| C12 | **[Registro-fundação](registro-fundacao.md)** | Versions em `votePledge`; sinais tipados no `municipalityUpdate`; `actionPlan.origin`+`resourceCost`; collection `allocationDecision`; garantia "nunca sobrescrever" | O-E, O-G(base) | B | ~2d | 2 |
| C12 | **[Registro-fundação](registro-fundacao.md)** | Versions em `votePledge`; sinais tipados no `municipalityUpdate`; `actionPlan.origin` + demandas/custo vinculado; collection `allocationDecision`; garantia "nunca sobrescrever" | O-E, O-G(base) | B | ~2d | 2 |
| E16 | **[Dossiê do município](dossie-municipio.md)** | Dossiê pré-visita/print compondo série+captura, pledges/cobertura, lideranças, conjuntura (`politicalTrend`), forças/riscos, dobradinhas, sinais recentes e perfil A8; emendas quando houver dado (G11) — pedido explícito de campo (O6 do CUSTOMER.md) | O6 (campo) | B | ~1d | 2 |
| E10 | **[Classificação territorial relativa](classificacao-territorial-relativa.md)** | Recalibra defesa/ataque/indecisa/perdida com âncoras relativas (LQ vs. share estadual próprio; múltiplos do share da cadeira marginal), multi-eixo (dominância+importância+competição+teto do campo); substitui limiares 35/20/10 de `electionInsights.ts` para DF | O-D | B | ~1d | 3 |
| B13 | **[Escala relativa no mapa](escala-relativa-mapa.md)** | Modo de escala novo: quantis do próprio candidato (5 classes) default, LQ e rank no município; símbolo proporcional por votos em jogo sobre o polígono; % dos válidos mantido como opção; roll-off como métrica adicional | O-C | B | ~2d | 3 |

View file

@ -1,7 +1,7 @@
# C12 — Registro-fundação (trajetória, sinais tipados, origem de visita, decisões ex-ante)
Status: rascunho
Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening)
Status: entregue em código
Atualizado em: 2026-07-24 (implementação, migration, testes e simplify concluídos)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", C12; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md))
Impeccable: B — sem rota nova; campos novos em forms existentes (`municipalityUpdate`, `actionPlan`) + collection admin-hidden
Appetite: ~2 dias eng; 1 migration consolidada
@ -27,7 +27,7 @@ FU4 do relatório: backtest compara o que se sabia antes com o que aconteceu dep
- **G1 — trajetória de pledge:** `versions: true` (sem drafts) em `votePledge`; leitura da série por município/liderança para o delta semanal (E8) e o backtest (E15).
- **G2 — sinais tipados:** `municipalityUpdate.kind` ganha `sinal`; campos condicionais `signalType` (`invasao | esfriamento | visita_adversario | proposta_broker | outro`), `signalSource` (texto curto), `triangulated` (checkbox staff-only). Continua imutável e mantendo `municipality.lastUpdateAt`.
- **G3 — origem/custo de plano:** `actionPlan.origin` (`dado | pedido_broker | obrigacao_politica`, default `dado`, staff-only) + `resourceCost` (number, staff-only, opcional).
- **G3 — origem/custo de plano:** `actionPlan.origin` (`dado | pedido_broker | obrigacao_politica`, default `dado`, staff-only) + custo derivado de `campaignDemand.cost` nas demandas vinculadas ao plano. A relação opcional `campaignDemand.actionPlan` já existe e só aceita o mesmo município.
- **G4 — decisões ex-ante:** collection `allocationDecision` (admin group `Campanha`, `admin.hidden` como `supporterImportBatch`): `municipality`, `patternId` (texto — P1…K-C ou `manual`), `suggestion` (texto), `decision` (`aceita | descartada | adiada`), `alternativeReading` (obrigatório ao descartar), `snapshot` (json — classificação/números no momento), `decidedBy` (derivado), `note`. Imutável após criação.
- Access: tudo staff; `leader` não lê sinais staff-only (`triangulated`), não lê `allocationDecision`, não lê `origin`/`resourceCost` — mesmo padrão `canReadCampaignStaffField`.
- Garantia transversal: nenhuma action nova sobrescreve histórico; escritas multi-collection com `withPayloadTransaction` + `req`.
@ -38,7 +38,7 @@ FU4 do relatório: backtest compara o que se sabia antes com o que aconteceu dep
- **Histórico via Payload `versions`, não collection de snapshots.** Menos código, admin de graça, migration gerada. **Rejeitado:** `pledgeSnapshot` própria (duplica access/hooks); campo `history` json (não consultável, cresce sem índice).
- **Decisão registrada é collection própria e imutável** — é o dado que o backtest paga caro para ter; mudanças de nível N0–N4 (E14) também gravam aqui, um mecanismo só. **Rejeitado:** log em `municipalityUpdate` (semântica de feed de campo, autoria de liderança possível — decisão é de coordenação); campo no `municipality` (sobrescreve).
- **Sinal nasce no form existente de atualização** (custo de digitação mínimo; "o campo dita, a sede registra" continua possível por quem tiver acesso). **Rejeitado:** collection nova de sinais (segundo feed; FU1 pede agregação, não outra caixa).
- **i18n e naming:** `signalType`, `signalSource`, `triangulated`, `origin`, `resourceCost`, `allocationDecision`, `patternId`, `alternativeReading`; enums como valores de dado em pt (`invasao`, `pedido_broker`, `aceita`) por paridade com enums existentes (`semanal`, `alta`); labels pt-BR.
- **i18n e naming:** `signalType`, `signalSource`, `triangulated`, `origin`, `allocationDecision`, `patternId`, `alternativeReading`; enums como valores de dado em pt (`invasao`, `pedido_broker`, `aceita`) por paridade com enums existentes (`semanal`, `alta`); labels pt-BR.
## Questões em aberto
@ -51,7 +51,7 @@ FU4 do relatório: backtest compara o que se sabia antes com o que aconteceu dep
flowchart LR
Pledge["votePledge + versions"]
Update["municipalityUpdate kind=sinal<br/>(signalType/source/triangulated)"]
Plan["actionPlan origin/resourceCost"]
Plan["actionPlan origin + demandas/custo"]
Dec["allocationDecision<br/>(imutável, staff)"]
Series["leitores de série<br/>(E8 delta · E11 gatilhos · E15 backtest)"]
Pledge --> Series
@ -63,11 +63,10 @@ flowchart LR
Componentes:
- **`src/collections/VotePledge.ts`**: `versions: true`; sem mudança de campos.
- **`src/collections/MunicipalityUpdate.ts`**: opção `sinal` no kind + 3 campos condicionais (`admin.condition` como os campos de `semanal`); access staff no `triangulated`.
- **`src/collections/ActionPlan.ts`**: `origin` + `resourceCost` com `canReadCampaignStaffField`.
- **`src/collections/MunicipalityUpdate.ts`**: opção `sinal` no kind + `body` curto, `signalType`, `signalSource` e `triangulated` condicional; access staff no `triangulated`.
- **`src/collections/ActionPlan.ts`**: `origin` com `canReadCampaignStaffField`; custo é lido das `campaignDemand` vinculadas, sem duplicar valor no plano.
- **`src/collections/AllocationDecision.ts`** (nova): imutável (update/delete admin-only), `createdBy` derivado no hook (padrão `Supporter.createdBy`).
- **`src/utilities/pledgeHistory.ts`**: leitura da série de versions por município (consumida por E8/E15).
- **Actions:** `recordAllocationDecision` em `src/app/(campaign)/campanha/actions/` (usada por E11; utilizável manualmente antes disso), com `withPayloadTransaction`.
- **Leituras e actions downstream:** E8/E15 definem o leitor de versions conforme seu contrato; E11 define a action de criar `allocationDecision` junto ao fluxo de sugestão. C12 entrega as estruturas e seus invariantes, sem abstrações sem consumidor.
- **Migration**: `pnpm migrate:create add_intel_foundation` — tabelas de versions do `votePledge`, campos novos, collection `allocationDecision`.
## Dependências
@ -75,6 +74,13 @@ Componentes:
- Dura: nenhuma pendente — deploy da remodelagem aplicado em produção (2026-07-23). Nenhuma de outro plano do programa (é a fundação; E8 usa se existir).
- Reusa: `withPayloadTransaction` (`payloadTransaction.ts`), `campaignAccess.ts`, padrão `createdBy` de `Supporter.ts`, `municipalityUpdatePageData.ts` (feed).
## Dados → decisão → apresentação
- **Dados:** sinais de rede, causalidade da visita e custo de demandas ligadas ao plano.
- **Decisão:** registrar um fato de campo datado, distinguir visita justificada de pedido político e julgar o recurso já comprometido com cada plano.
- **Forma:** formulário curto e feed cronológico com badges de tipo/fonte/triangulação; lista de demandas e soma em R$ no detalhe do plano. Sem KPI, gráfico ou mapa nesta fatia.
- **Anti-goals:** segundo feed, formulário de inteligência longo, dashboard de custos ou métrica estadual absoluta.
## Não escopo
- Computar gatilhos sobre os sinais (E11); UI de leitura da trajetória (E8 delta/E15); nível N0–N4 em si (E14 — só o registro da mudança mora aqui); notificações de sinal (D2).
@ -90,6 +96,11 @@ Componentes:
- **Versions em `municipality` (histórico de estratégia).** Gatilho: E15 precisar de foto além do `allocationDecision.snapshot` em caso real.
- **Import de sinais via WhatsApp/copy-paste estruturado.** Gatilho: volume real de sinais >20/semana com reclamação de digitação.
## Revisões
- **2026-07-24:** G3 passa a reutilizar `campaignDemand.actionPlan` + `campaignDemand.cost`, já existentes; removido `resourceCost` duplicado no plano. Leitores de versions e action de decisão seguem para E8/E15 e E11, respectivamente.
- **2026-07-24:** implementação concluída com migration consolidada `20260724_180000_add_campaign_foundation_records`, testes de integração e jornada E2E; débitos de escala plano↔demandas absorvidos em C11.
## Referências
- `docs/roadmap.md` (Inteligência de campanha, C12) · [plano-mestre](inteligencia-campanha.md) (gaps G1–G4)

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-24 (B18 filtros salvos + atalho sidebar — [plano](plans/filtros-salvos-municipios.md); B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; fill-ins; E4R ✓; E17 ✓; B14; janela 1: smoke, R6, E8)
Atualizado em: 2026-07-24 (C12 ✓; C11 absorve escala plano↔demandas; B18; B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6, E8)
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)
@ -46,6 +46,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
- **Hardening de engenharia (2026-07-23, Fases 0–6)** — access lockdown (PII/CMS admin-only + gates de rota do leader), tooling gate (knip no CI, `--max-warnings=0`, ban `as never`), artefato TSE commitado + cache (`bahiaElectionAggregates`, `election-tse`, `loadMunicipalityScope`), pending honesto + Suspense streaming, state scoping, splits (`src/utilities/access/*`, `supporterImport.ts`, shells DRY). Tracker: [IMPROVE-CODE-QUALITY-PLAN.md](IMPROVE-CODE-QUALITY-PLAN.md) · ledger: [TECH-DEBT.md](TECH-DEBT.md) · mapa de testes: [TESTING.md](TESTING.md).
- **E4R (2026-07-24)** — import único da planilha de projeção → `municipality.expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` (`pnpm db:seed:projecao`, always-overwrite, dry-run + runbook; Salvador pulado; zero PII). No mesmo dia o grupo duplicado `voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido do app** (migration `20260724_133600` com backfill metas→estimativas) — a única série por cenário é `expectedVotes`. Plano: [import-planilha-projecao.md](plans/import-planilha-projecao.md). Seed local verificado (189 estimativas / 50 alta); produção após smoke.
- **E17 (2026-07-24)** — tabela comparativa dos 27 Territórios de Identidade no Início staff (`/campanha`): `territoryOverview.ts` (rollup puro client-safe: `computeTerritoryRollup` + `sortTerritoryRows`) + `loadTerritoryOverview.ts` (loader server-only, `overrideAccess: true`) + `TerritoryOverviewTable.tsx` (tabela densa, ordenação client-side default `% da própria votação desc`, Metropolitano decomposto em Salvador 19 zonas × Demais RMS, linha→`/campanha/municipios?region=<TI>`). Somas/razões apenas (salvaguarda MAUP); sem migration/collection. Primeira fatia de E12. Plano: [tabela-ti-inicio.md](plans/tabela-ti-inicio.md).
- **C12 (2026-07-24)** — registro-fundação: versions nativas em `votePledge` com baseline; sinais tipados em `municipalityUpdate`; origem do `actionPlan`; demandas vinculadas com custo derivado e criação múltipla atômica; `allocationDecision` ex-ante imutável. Migration `20260724_180000_add_campaign_foundation_records`; testes int + E2E. Plano: [registro-fundacao.md](plans/registro-fundacao.md).
## Próximos — Campanha (`/campanha`)
@ -57,7 +58,6 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
| --- | ---------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | ------------------------------ |
| E8 | Conta da cadeira | [detalhes](plans/conta-da-cadeira.md) | Global `campaignGoals`; potencial por município (válidos projetados, captura do campo, roll-off, headroom); decomposição meta→município; cobertura Σpledges÷meta (cenário default = média); meta inicial ancorada em campo: piso 150 mil (2022: 129k) | B | ~2d | 1–2 | — (A10 ✓, remodelagem em prod) |
| E9 | Fila de alocação | [detalhes](plans/fila-de-alocacao.md) | Lista de decisão (7 colunas + % da própria votação — absorve A11), ordenação por déficit descoberto (default do staff quando houver metas — registro no fluxo de poder) + risco por frescor, "coluna da vergonha" | B | ~1,5d | 2 | E8 |
| C12 | Registro-fundação | [detalhes](plans/registro-fundacao.md) | Versions em `votePledge`; sinais tipados no `municipalityUpdate`; `actionPlan.origin`+custo; collection `allocationDecision` | B | ~2d | 2 | — (paralelo a E8) |
| E16 | Dossiê do município (pré-agenda) | [detalhes](plans/dossie-municipio.md) | Dossiê pré-visita compondo dados existentes (série/captura, pledges/cobertura, lideranças, conjuntura, forças/riscos, dobradinhas, sinais, perfil A8) + emendas quando houver dado (G11); visão print — pedido explícito de campo (O6) | B | ~1d | 2 | — (melhora com E8/C12) |
| E10 | Classificação territorial relativa | [detalhes](plans/classificacao-territorial-relativa.md) | Âncoras relativas (LQ / share da cadeira marginal), multi-eixo; substitui 35/20/10 para DF | B | ~1d | 3 | E8 |
| B13 | Escala relativa no mapa | [detalhes](plans/escala-relativa-mapa.md) | Quantis do candidato (default) + LQ + rank; símbolo proporcional por votos em jogo; % válidos mantido | B | ~2d | 3 | E8, E10 |
@ -72,6 +72,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- ~~**E4R** import único da planilha de projeção~~ — **entregue 2026-07-24** (`pnpm db:seed:projecao`; overwrite-always; [plano](plans/import-planilha-projecao.md))
- ~~**A11** posição em votos do município (rank absoluto + % da própria votação) + ordenação da lista por votação~~ — **entregue 2026-07-24** (helper `municipalityVoteRank`; baseline + coluna lista; default `?sort=votos` desc; header=`2022`; consome contrato B15) · [plano](plans/ranking-votos-municipio.md)
- ~~**E17** tabela comparativa dos Territórios de Identidade no Início (pedido do candidato) · somas/razões apenas, Metropolitano decomposto (salvaguardas MAUP) · ~1d · Janela 1–2 · primeira fatia de E12 · [plano](plans/tabela-ti-inicio.md)~~ — **entregue 2026-07-24** (`territoryOverview.ts` + `loadTerritoryOverview.ts` + `TerritoryOverviewTable.tsx`; rollup puro client-safe, loader `overrideAccess: true`, Metropolitano decomposto, ordenação client-side; sem migration)
- ~~**C12** registro-fundação~~ — **entregue em código 2026-07-24** (versions, sinais, origem/custo derivado, decisões ex-ante; migration + testes) · [plano](plans/registro-fundacao.md)
- **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · camada de insight sobre o registro operacional `stateDeputy` (M4) · alimenta **E13** · [plano](plans/insight-dobradinha-2026.md)
- **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md)
- **B8 F2** polígonos dos Municípios-zona de Salvador (ZE 1–19; Camaçari saiu do escopo na M1 — município inteiro): F1 catálogo zona→bairros entregue (hoje Salvador-only); falta prep do catálogo (~½ dia) + dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável · [plano](plans/poligonos-pracas-zona.md)
@ -94,7 +95,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- **Cenário junto aos filtros** em `/campanha/municipios` — mover `VoteEstimateScenarioField` para a fileira/barra slim de `MunicipalityFilters` (com **B16**, a barra deixa de carregar os selects de coluna); overview só consome o contexto · ~0,25–0,5d · Impeccable B · soft: B16 · [plano](plans/cenario-junto-filtros-municipios.md)
- **O0+** escala/DRY pós-Onda 0 · [plano](plans/escala-dry-pos-onda0.md)
- **RS+** auth read leve + shells de senha · [plano](plans/escala-dry-pos-reset-senha-perfil.md)
- **C10 / C11** escala apoiadores/planos · reavaliar nas superfícies remodeladas · gatilhos originais intactos: base nominal crescendo ([plano](plans/escala-dry-pos-c9.md)) / volume de planos medido ([plano](plans/escala-dry-pos-c7.md))
- **C10 / C11** escala apoiadores/planos · C11 absorveu a extensão pós-C12 para picker de planos e paginação das demandas vinculadas (Fase 6 condicional; ≥100 planos ou ≥50 demandas/plano) · gatilhos: base nominal crescendo ([plano](plans/escala-dry-pos-c9.md)) / volume de planos medido ([plano](plans/escala-dry-pos-c7.md))
- **C8 F1–F3 restantes** perf de import/lista + DRY de helpers (F4 DRY de forms entregue; parte de F2/F3 absorvida pelo split de access) · gatilho: import em volume real pós-Onda 0 · [plano](plans/escala-dry-pos-c6.md)
- Higiene PascalCase de componentes legados
@ -173,13 +174,13 @@ flowchart TD
B8F2 -.ZE Salvador.-> B14n
```
Paralelizáveis agora: **E8** (A10 ✓ e remodelagem em produção), **C12** (paralelo a E8), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B16** (filtros no header; soft B15 ✓ já entregue), **B17** (seletor de colunas; soft B15/B16 — pousa na barra atual ou slim), **B18** (filtros salvos + submenu Municípios; soft B16/B17 — botão na barra), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros — encaixa na barra slim de B16 —, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9).
Paralelizáveis agora: **E8** (A10 ✓, C12 ✓ e remodelagem em produção), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B16** (filtros no header; soft B15 ✓ já entregue), **B17** (seletor de colunas; soft B15/B16 — pousa na barra atual ou slim), **B18** (filtros salvos + submenu Municípios; soft B16/B17 — botão na barra), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros — encaixa na barra slim de B16 —, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9).
### Sequência por janela (só pendentes)
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + default `sort=votos`) entregues 2026-07-24; **B16** (filtros no header) / **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E8** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **E8** conta da cadeira → **E9** fila de alocação, com **C12** registro-fundação em paralelo (migrations cedo, longe do congelamento); **E16** dossiê do município (pedido O6 — compõe o existente, melhora com E8); D2 se sobrar folga.
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **C12 ✓** registro-fundação já em código; **E8** conta da cadeira → **E9** fila de alocação; **E16** dossiê do município (pedido O6 — compõe o existente, melhora com E8); D2 se sobrar folga.
**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08); **E10** classificação relativa → **B13** escala relativa no mapa; **E14** níveis N0–N4; **E11** motor de sugestões v1; **E12** camada TI; **E13** planejador de giros; **B8 F2** polígonos das zonas de Salvador; débitos sobreviventes. Se houver folga **e** os deltas do ZAP não estiverem sendo absorvidos pelo fluxo sede-digita (C12): começar **D3**.

View file

@ -79,6 +79,18 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps
>
{demand.municipalityName}
</Link>
{demand.actionPlan ? (
<>
{' '}
· Plano:{' '}
<Link
href={`/campanha/planos/${demand.actionPlan.slug}`}
className="text-primary underline-offset-4 hover:underline"
>
{demand.actionPlan.title}
</Link>
</>
) : null}
{demand.requesterName ? ` · Solicitada por ${demand.requesterName}` : ''} · Aberta em{' '}
{dateTimeFormatter.format(new Date(demand.createdAt))}
</p>

View file

@ -1,7 +1,12 @@
'use server'
import { createCampaignDemand } from '@/app/(campaign)/campanha/actions/demand'
import { optionalFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import {
nullableRelationshipFormValue,
optionalFormText,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { campaignDemandKinds, type CampaignDemandKind } from '@/lib/schemas/campaignDemand'
import {
runCampaignRedirectFormAction,
@ -22,6 +27,7 @@ export const createDemandFormAction = async (
: 'outro',
description: optionalFormText(formData, 'description'),
municipality: requiredRelationshipFormValue(formData, 'municipalityId'),
actionPlan: nullableRelationshipFormValue(formData, 'actionPlanId') ?? undefined,
})
},
redirectTo: (demand) => `/campanha/demandas/${demand.slug}`,

View file

@ -9,15 +9,34 @@ import { DemandForm } from '@/components/campaign/DemandForm'
import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl'
import { loadActionPlanOptions, loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { createDemandFormAction } from './formActions'
export default async function NewDemandPage() {
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
type NewDemandPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function NewDemandPage({ searchParams }: NewDemandPageProps) {
const [user, payload, query] = await Promise.all([
getCampaignUser(),
getPayload({ config }),
searchParams,
])
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha')
const municipalityOptions = await loadMunicipalityOptions(payload, user)
const [municipalityOptions, actionPlanOptions] = await Promise.all([
loadMunicipalityOptions(payload, user),
loadActionPlanOptions(payload, user),
])
const requestedActionPlan = actionPlanOptions.find(
(plan) => plan.id === strictDecimalInteger(firstValue(query.actionPlan)),
)
const requestedMunicipalityId = strictDecimalInteger(firstValue(query.municipality))
const initialMunicipalityId =
requestedActionPlan?.municipalityId ??
municipalityOptions.find((option) => option.id === requestedMunicipalityId)?.id
return (
<CampaignPageShell>
@ -35,7 +54,13 @@ export default async function NewDemandPage() {
</p>
</header>
<DemandForm municipalityOptions={municipalityOptions} formAction={createDemandFormAction} />
<DemandForm
municipalityOptions={municipalityOptions}
actionPlanOptions={actionPlanOptions}
initialMunicipalityId={initialMunicipalityId}
initialActionPlanId={requestedActionPlan?.id}
formAction={createDemandFormAction}
/>
</CampaignPageShell>
)
}

View file

@ -4,12 +4,18 @@ import { revalidatePath } from 'next/cache'
import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate'
import {
checkboxFormValue,
optionalFormText,
optionalIntegerFormValue,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { municipalityUpdateKinds, type MunicipalityUpdateKind } from '@/lib/schemas/municipalityUpdate'
import {
municipalitySignalTypes,
municipalityUpdateKinds,
type MunicipalitySignalType,
type MunicipalityUpdateKind,
} from '@/lib/schemas/municipalityUpdate'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
@ -26,6 +32,11 @@ export const createMunicipalityUpdateFormAction = async (
? (rawKind as MunicipalityUpdateKind)
: 'semanal'
const rawSignalType = optionalFormText(formData, 'signalType')
const signalType = municipalitySignalTypes.includes(rawSignalType as MunicipalitySignalType)
? (rawSignalType as MunicipalitySignalType)
: undefined
await createMunicipalityUpdate({
municipality,
kind,
@ -35,6 +46,9 @@ export const createMunicipalityUpdateFormAction = async (
body: optionalFormText(formData, 'body'),
activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'),
newSupports: optionalIntegerFormValue(formData, 'newSupports'),
signalType,
signalSource: optionalFormText(formData, 'signalSource'),
triangulated: checkboxFormValue(formData, 'triangulated'),
})
revalidatePath('/campanha/municipios/[slug]', 'page')
return { status: 'success', message: 'Atualização registrada com sucesso.' }

View file

@ -17,7 +17,8 @@ import { ActionPlanUpdateForm } from '@/components/campaign/ActionPlanUpdateForm
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import { actionPlanKindLabels, actionPlanOriginLabels } from '@/lib/schemas/actionPlan'
import { campaignDemandKindLabels, campaignDemandStatusLabels } from '@/lib/schemas/campaignDemand'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
@ -36,6 +37,11 @@ type ActionPlanDetailPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
const currencyFormatter = new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
})
export default async function ActionPlanDetailPage({
params,
searchParams,
@ -63,8 +69,7 @@ export default async function ActionPlanDetailPage({
if (canonicalTabRedirect) redirect(canonicalTabRedirect)
const isStaff = isCampaignStaff(user)
const canManageLifecycle =
isStaff && view.status !== 'realizado' && view.status !== 'cancelado'
const canManageLifecycle = isStaff && view.status !== 'realizado' && view.status !== 'cancelado'
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
@ -160,6 +165,10 @@ export default async function ActionPlanDetailPage({
<dt className="text-muted-foreground">Responsável</dt>
<dd>{view.responsibleName ?? 'Não definido'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Origem da ação</dt>
<dd>{actionPlanOriginLabels[view.origin]}</dd>
</div>
</dl>
</CardContent>
</Card>
@ -205,6 +214,61 @@ export default async function ActionPlanDetailPage({
</Card>
</div>
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<CardTitle>Demandas vinculadas</CardTitle>
<p className="text-sm text-muted-foreground">
Custo estimado: {currencyFormatter.format(view.demandCostTotal)}
</p>
</div>
{view.municipality ? (
<Button asChild variant="outline" className="min-h-11 shrink-0">
<Link
href={`/campanha/demandas/nova?actionPlan=${view.id}&municipality=${view.municipality.id}`}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Adicionar demanda
</Link>
</Button>
) : null}
</CardHeader>
<CardContent>
{view.demands.length ? (
<ul role="list" className="divide-y">
{view.demands.map((demand) => (
<li
key={demand.id}
className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<Link
href={`/campanha/demandas/${demand.slug}`}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{demand.title}
</Link>
<p className="text-sm text-muted-foreground">
{campaignDemandKindLabels[demand.kind]} ·{' '}
{campaignDemandStatusLabels[demand.status]}
</p>
</div>
<span className="text-sm text-muted-foreground">
{demand.cost == null
? 'Custo não registrado'
: currencyFormatter.format(demand.cost)}
</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Nenhuma demanda vinculada a este plano.
</p>
)}
</CardContent>
</Card>
{view.status === 'realizado' && isStaff ? (
<ActionPlanResultForm
planId={view.id}

View file

@ -16,6 +16,16 @@ import {
import { slugify } from '@/utilities/slug'
const getActionError = (error: unknown): ActionPlanFormState => {
if (
error instanceof Error &&
/campaign_demand.*(?:slug|title)|campaignDemand.*(?:slug|title)/i.test(error.message)
) {
return {
fieldErrors: {
demandsJson: ['Já existe uma demanda com um dos títulos informados.'],
},
}
}
// Checked before the shared mapper: a DB-level unique-violation message isn't
// a `FormDataBoundaryError`/`ZodError`/known-safe string, so it needs its own
// branch to surface as a field error instead of falling to the generic message.
@ -62,7 +72,8 @@ export const createActionPlanFormAction = async (
let planSlug: string
try {
const plan = await createActionPlan(parseActionPlanCreateFormData(formData))
const { demands, ...planInput } = parseActionPlanCreateFormData(formData)
const plan = await createActionPlan(planInput, demands)
planSlug = plan.slug
} catch (error) {
const existing = await existingActionPlanState(formData)
@ -80,8 +91,8 @@ export const updateActionPlanFormAction = async (
let planSlug: string
try {
const input = parseActionPlanUpdateFormData(formData)
const plan = await updateActionPlan(input)
const { demands, ...planInput } = parseActionPlanUpdateFormData(formData)
const plan = await updateActionPlan(planInput, demands)
planSlug = plan.slug
} catch (error) {
return getActionError(error)

View file

@ -4,7 +4,9 @@ import type { Payload } from 'payload'
import {
actionPlanCreateSchema,
actionPlanDemandDraftsSchema,
actionPlanUpdateSchema,
type ActionPlanDemandDraft,
type ActionPlanCreateInput,
type ActionPlanUpdateInput,
} from '@/lib/schemas/actionPlan'
@ -22,31 +24,60 @@ export const createActionPlanRecord = async (
payload: Payload,
actor: CampaignUser,
input: ActionPlanCreateInput,
demandDrafts: ActionPlanDemandDraft[] = [],
) => {
const data = actionPlanCreateSchema.parse(input)
const demands = actionPlanDemandDraftsSchema.parse(demandDrafts)
return payload.create({
collection: 'actionPlan',
data: hookFilledCreateData<'actionPlan'>(data),
depth: 0,
user: actor,
overrideAccess: false,
})
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
const plan = await payload.create({
collection: 'actionPlan',
data: hookFilledCreateData<'actionPlan'>(data),
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
for (const demand of demands) {
await payload.create({
collection: 'campaignDemand',
data: hookFilledCreateData<'campaignDemand'>({
...demand,
municipality: data.municipality,
actionPlan: plan.id,
}),
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
}
return plan
},
{ beginFailureMessage: 'Não foi possível iniciar a criação do plano.' },
)
}
export const updateActionPlanRecord = async (
payload: Payload,
actor: CampaignUser,
input: ActionPlanUpdateInput,
demandDrafts: ActionPlanDemandDraft[] = [],
) => {
const { id, ...data } = actionPlanUpdateSchema.parse(input)
const demands = actionPlanDemandDraftsSchema.parse(demandDrafts)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
return payload.update({
const plan = await payload.update({
collection: 'actionPlan',
id,
data,
@ -55,6 +86,25 @@ export const updateActionPlanRecord = async (
overrideAccess: false,
req,
})
const municipality =
typeof plan.municipality === 'number' ? plan.municipality : plan.municipality.id
for (const demand of demands) {
await payload.create({
collection: 'campaignDemand',
data: hookFilledCreateData<'campaignDemand'>({
...demand,
municipality,
actionPlan: plan.id,
}),
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
}
return plan
},
{ beginFailureMessage: 'Não foi possível iniciar a atualização do plano.' },
)
@ -211,14 +261,20 @@ export const registerActionPlanResult = async (
)
}
export const createActionPlan = async (input: ActionPlanCreateInput) => {
export const createActionPlan = async (
input: ActionPlanCreateInput,
demandDrafts: ActionPlanDemandDraft[] = [],
) => {
const { payload, actor } = await getCampaignActionContext()
return createActionPlanRecord(payload, actor, input)
return createActionPlanRecord(payload, actor, input, demandDrafts)
}
export const updateActionPlan = async (input: ActionPlanUpdateInput) => {
export const updateActionPlan = async (
input: ActionPlanUpdateInput,
demandDrafts: ActionPlanDemandDraft[] = [],
) => {
const { payload, actor } = await getCampaignActionContext()
return updateActionPlanRecord(payload, actor, input)
return updateActionPlanRecord(payload, actor, input, demandDrafts)
}
export const cancelActionPlan = async (id: number) => {

View file

@ -6,6 +6,8 @@ import type {
import { APIError } from 'payload'
import {
actionPlanOriginLabels,
actionPlanOrigins,
actionPlanKindLabels,
actionPlanKinds,
actionPlanStatusLabels,
@ -15,8 +17,10 @@ import {
canCreateActionPlan,
canCreateActionPlanAdvisors,
canDeleteActionPlan,
canManageCampaignStaffField,
canManageActionPlanAdvisors,
canReadActionPlan,
canReadCampaignStaffField,
canSetActionPlanStatus,
canSetActionPlanSystemField,
canUpdateActionPlan,
@ -40,6 +44,11 @@ const ACTION_PLAN_STATUS_OPTIONS = actionPlanStatuses.map((value) => ({
label: actionPlanStatusLabels[value],
}))
const ACTION_PLAN_ORIGIN_OPTIONS = actionPlanOrigins.map((value) => ({
value,
label: actionPlanOriginLabels[value],
}))
const relationshipIds = (value: unknown): number[] =>
(Array.isArray(value) ? value : []).map(relationshipId).filter((id): id is number => id !== null)
@ -61,6 +70,7 @@ const actionPlanStaffFieldSnapshot = (doc: Record<string, unknown>) => ({
leadership: relationshipId(doc.leadership),
resultSummary: trimmedText(doc.resultSummary),
resultMedia: relationshipIds(doc.resultMedia),
origin: doc.origin ?? null,
})
const setCanonicalActionPlanSlug: CollectionBeforeValidateHook = ({
@ -328,6 +338,18 @@ export const ActionPlan: CollectionConfig = {
label: 'Descrição',
maxLength: 4000,
},
{
name: 'origin',
type: 'select',
label: 'Origem da ação',
defaultValue: 'dado',
options: ACTION_PLAN_ORIGIN_OPTIONS,
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
},
{
name: 'deputyPresent',
type: 'checkbox',

View file

@ -0,0 +1,147 @@
import { Buffer } from 'node:buffer'
import type {
CollectionBeforeChangeHook,
CollectionBeforeValidateHook,
CollectionConfig,
} from 'payload'
import { APIError } from 'payload'
import {
canCreateAllocationDecision,
canMutateAllocationDecision,
canReadAllocationDecision,
canSetCampaignSystemField,
} from '@/utilities/campaignAccess'
const MAX_SERIALIZED_SNAPSHOT_BYTES = 16_000
const validateAllocationDecision: CollectionBeforeValidateHook = ({
data,
operation,
originalDoc,
}) => {
if (!data) return data
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
if (
nextData.outcome === 'descarta' &&
(typeof nextData.alternativeReading !== 'string' || !nextData.alternativeReading.trim())
) {
throw new APIError('Informe a leitura alternativa ao descartar a sugestão.', 400)
}
try {
const serializedSnapshot = JSON.stringify(nextData.snapshot)
if (
typeof nextData.snapshot !== 'object' ||
nextData.snapshot === null ||
Array.isArray(nextData.snapshot) ||
Buffer.byteLength(serializedSnapshot, 'utf8') > MAX_SERIALIZED_SNAPSHOT_BYTES
) {
throw new APIError('O recorte da decisão está grande demais.', 400)
}
} catch (error) {
if (error instanceof APIError) throw error
throw new APIError('O recorte da decisão é inválido.', 400)
}
return data
}
const deriveDecisionAuthor: CollectionBeforeChangeHook = ({ data, operation, req }) => {
if (operation === 'create' && req.user?.collection === 'campaignUser') {
data.decidedBy = req.user.id
}
return data
}
export const AllocationDecision: CollectionConfig = {
slug: 'allocationDecision',
labels: {
singular: 'Decisão de alocação',
plural: 'Decisões de alocação',
},
admin: {
group: 'Campanha',
hidden: true,
useAsTitle: 'patternId',
},
access: {
create: canCreateAllocationDecision,
read: canReadAllocationDecision,
update: canMutateAllocationDecision,
delete: canMutateAllocationDecision,
},
hooks: {
beforeValidate: [validateAllocationDecision],
beforeChange: [deriveDecisionAuthor],
},
fields: [
{
name: 'municipality',
type: 'relationship',
relationTo: 'municipality',
label: 'Município',
required: true,
index: true,
},
{
name: 'patternId',
type: 'text',
label: 'Padrão avaliado',
required: true,
maxLength: 120,
index: true,
},
{
name: 'outcome',
type: 'select',
label: 'Decisão',
required: true,
options: [
{ label: 'Aceita', value: 'aceita' },
{ label: 'Descarta', value: 'descarta' },
],
},
{
name: 'rationale',
type: 'textarea',
label: 'Justificativa',
required: true,
maxLength: 2000,
},
{
name: 'alternativeReading',
type: 'textarea',
label: 'Leitura alternativa',
maxLength: 2000,
admin: {
condition: (_, siblingData) => siblingData.outcome === 'descarta',
},
},
{
name: 'snapshot',
type: 'json',
label: 'Recorte da decisão',
required: true,
admin: {
description: 'Somente os números e classificações usados no momento da decisão.',
},
},
{
name: 'decidedBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Decidido por',
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
],
}

View file

@ -23,6 +23,7 @@ import {
canUpdateCampaignDemand,
isCampaignUnrestricted,
} from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship'
import { slugify } from '@/utilities/slug'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
@ -135,6 +136,48 @@ const enforceDemandWorkflow: CollectionBeforeChangeHook = async ({
return data
}
const validateDemandActionPlanMunicipality: CollectionBeforeValidateHook = async ({
data,
operation,
originalDoc,
req,
}) => {
if (!data) return data
const municipalityID = relationshipId(data.municipality ?? originalDoc?.municipality)
const actionPlanID = relationshipId(data.actionPlan ?? originalDoc?.actionPlan)
if (!actionPlanID) return data
if (!municipalityID) throw new APIError('Município da demanda inválido.', 400)
if (
operation === 'update' &&
municipalityID === relationshipId(originalDoc?.municipality) &&
actionPlanID === relationshipId(originalDoc?.actionPlan)
) {
return data
}
const requestContext = req.context as Record<string, unknown>
const cacheKey = `campaignDemand:actionPlanMunicipality:${actionPlanID}`
let actionPlanMunicipalityID = requestContext[cacheKey]
if (typeof actionPlanMunicipalityID !== 'number') {
const actionPlan = await req.payload.findByID({
collection: 'actionPlan',
id: actionPlanID,
depth: 0,
select: { municipality: true },
overrideAccess: true,
req,
})
actionPlanMunicipalityID = relationshipId(actionPlan.municipality)
requestContext[cacheKey] = actionPlanMunicipalityID
}
if (actionPlanMunicipalityID !== municipalityID) {
throw new APIError('A demanda e o plano de ação devem pertencer ao mesmo município.', 409)
}
return data
}
export const CampaignDemand: CollectionConfig = {
slug: 'campaignDemand',
labels: {
@ -155,7 +198,7 @@ export const CampaignDemand: CollectionConfig = {
delete: canDeleteCampaignDemand,
},
hooks: {
beforeValidate: [setCanonicalDemandSlug],
beforeValidate: [setCanonicalDemandSlug, validateDemandActionPlanMunicipality],
beforeChange: [enforceDemandWorkflow],
},
fields: [

View file

@ -9,9 +9,17 @@ import type {
} from 'payload'
import { APIError } from 'payload'
import {
municipalitySignalTypeLabels,
municipalitySignalTypes,
municipalityUpdateKindLabels,
municipalityUpdateKinds,
} from '@/lib/schemas/municipalityUpdate'
import {
canCreateMunicipalityUpdate,
canManageCampaignStaffField,
canMutateMunicipalityUpdate,
canReadCampaignStaffField,
canReadMunicipalityUpdate,
canSetMunicipalityUpdateAuthor,
} from '@/utilities/campaignAccess'
@ -19,6 +27,14 @@ import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { relationshipId } from '@/utilities/relationship'
const DERIVED_MUNICIPALITY_UPDATE_CONTEXT = 'municipalityUpdateDerivedField'
const MUNICIPALITY_UPDATE_KIND_OPTIONS = municipalityUpdateKinds.map((value) => ({
label: municipalityUpdateKindLabels[value],
value,
}))
const MUNICIPALITY_SIGNAL_TYPE_OPTIONS = municipalitySignalTypes.map((value) => ({
label: municipalitySignalTypeLabels[value],
value,
}))
const nonEmptyText = (value: unknown): boolean =>
typeof value === 'string' && value.trim().length > 0
@ -37,10 +53,22 @@ const validateMunicipalityUpdateKind: CollectionBeforeValidateHook = ({
if (!nonEmptyText(nextData.worked)) throw new APIError('Informe o que funcionou.', 400)
if (!nonEmptyText(nextData.failed)) throw new APIError('Informe o que não funcionou.', 400)
if (!nonEmptyText(nextData.needs)) throw new APIError('Informe o que você precisa.', 400)
} else if (kind === 'sinal') {
if (!nonEmptyText(nextData.body)) throw new APIError('Informe o texto da atualização.', 400)
if (typeof nextData.signalType !== 'string') {
throw new APIError('Informe o tipo do sinal.', 400)
}
if (!nonEmptyText(nextData.signalSource)) throw new APIError('Informe a fonte do sinal.', 400)
} else if (!nonEmptyText(nextData.body)) {
throw new APIError('Informe o texto da atualização.', 400)
}
if (kind !== 'sinal') {
data.signalType = null
data.signalSource = null
data.triangulated = false
}
return data
}
@ -56,7 +84,10 @@ const acquireMunicipalityUpdateLocks = (req: PayloadRequest, municipalityIDs: nu
municipalityIDs.map((municipalityID) => `municipality-updates:${municipalityID}`),
)
const recomputeMunicipalityLastUpdateAt = async (req: PayloadRequest, municipalityIDs: number[]) => {
const recomputeMunicipalityLastUpdateAt = async (
req: PayloadRequest,
municipalityIDs: number[],
) => {
for (const municipalityID of municipalityIDs) {
const latest = await req.payload.find({
collection: 'municipalityUpdate',
@ -206,11 +237,7 @@ export const MunicipalityUpdate: CollectionConfig = {
required: true,
defaultValue: 'semanal',
index: true,
options: [
{ label: 'Semanal', value: 'semanal' },
{ label: 'Urgente', value: 'urgente' },
{ label: 'Nota', value: 'nota' },
],
options: MUNICIPALITY_UPDATE_KIND_OPTIONS,
},
{
name: 'worked',
@ -260,5 +287,38 @@ export const MunicipalityUpdate: CollectionConfig = {
condition: (_, siblingData) => siblingData.kind !== 'semanal',
},
},
{
name: 'signalType',
type: 'select',
label: 'Tipo de sinal',
options: MUNICIPALITY_SIGNAL_TYPE_OPTIONS,
admin: {
condition: (_, siblingData) => siblingData.kind === 'sinal',
},
},
{
name: 'signalSource',
type: 'text',
label: 'Fonte do sinal',
maxLength: 160,
admin: {
condition: (_, siblingData) => siblingData.kind === 'sinal',
},
},
{
name: 'triangulated',
type: 'checkbox',
label: 'Triangulado',
defaultValue: false,
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
admin: {
condition: (_, siblingData) => siblingData.kind === 'sinal',
description: 'Confirmado por mais de uma fonte de campo.',
},
},
],
}

View file

@ -9,6 +9,7 @@ import {
canReadVotePledge,
canSetCampaignSystemField,
canUpdateVotePledge,
payloadAdminOnly,
} from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship'
import {
@ -142,9 +143,13 @@ export const VotePledge: CollectionConfig = {
access: {
create: canCreateVotePledge,
read: canReadVotePledge,
readVersions: payloadAdminOnly,
update: canUpdateVotePledge,
delete: canDeleteVotePledge,
},
versions: {
maxPerDoc: 0,
},
indexes: [
{
fields: ['leadership', 'municipality'],

View file

@ -0,0 +1,140 @@
'use client'
import { useId, useState } from 'react'
import { PlusIcon, Trash2Icon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Textarea } from '@/components/ui/textarea'
import { campaignDemandKindLabels, campaignDemandKinds } from '@/lib/schemas/campaignDemand'
import { MAX_ACTION_PLAN_DEMAND_DRAFTS } from '@/lib/schemas/actionPlan'
type DemandFieldValue = {
key: string
title: string
kind: (typeof campaignDemandKinds)[number]
description: string
}
let demandKeySequence = 0
const nextDemandKey = (): string => {
demandKeySequence += 1
return `demand-${demandKeySequence}`
}
const serializeDemands = (demands: DemandFieldValue[]) =>
JSON.stringify(
demands
.filter((demand) => demand.title.trim())
.map((demand) => ({
title: demand.title.trim(),
kind: demand.kind,
...(demand.description.trim() ? { description: demand.description.trim() } : {}),
})),
)
export const ActionPlanDemandFields = ({ error }: { error?: string }) => {
const fieldId = useId()
const [demands, setDemands] = useState<DemandFieldValue[]>([])
const addDemand = () => {
setDemands((current) => [
...current,
{ key: nextDemandKey(), title: '', kind: 'material', description: '' },
])
}
const updateDemand = (key: string, patch: Partial<DemandFieldValue>) => {
setDemands((current) =>
current.map((demand) => (demand.key === key ? { ...demand, ...patch } : demand)),
)
}
const removeDemand = (key: string) => {
setDemands((current) => current.filter((demand) => demand.key !== key))
}
return (
<Field data-invalid={Boolean(error)}>
<FieldLabel htmlFor={fieldId}>Demandas vinculadas</FieldLabel>
<FieldDescription>
Registre necessidades que surgem deste plano. Município e plano serão vinculados
automaticamente.
</FieldDescription>
<input
type="hidden"
id={fieldId}
name="demandsJson"
value={serializeDemands(demands)}
readOnly
/>
<div className="flex flex-col gap-3">
{demands.map((demand, index) => (
<div key={demand.key} className="flex flex-col gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">Demanda {index + 1}</p>
<Button
type="button"
variant="ghost"
className="min-h-11"
onClick={() => removeDemand(demand.key)}
aria-label={`Remover demanda ${index + 1}`}
>
<Trash2Icon aria-hidden="true" />
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-[2fr_1fr]">
<Input
aria-label={`Título da demanda ${index + 1}`}
placeholder="O que é necessário?"
value={demand.title}
minLength={2}
maxLength={160}
className="min-h-11"
onChange={(event) => updateDemand(demand.key, { title: event.target.value })}
required
/>
<NativeSelect
aria-label={`Tipo da demanda ${index + 1}`}
value={demand.kind}
onChange={(event) =>
updateDemand(demand.key, {
kind: event.target.value as DemandFieldValue['kind'],
})
}
className="min-h-11 w-full"
>
{campaignDemandKinds.map((kind) => (
<NativeSelectOption key={kind} value={kind}>
{campaignDemandKindLabels[kind]}
</NativeSelectOption>
))}
</NativeSelect>
</div>
<Textarea
aria-label={`Descrição da demanda ${index + 1}`}
placeholder="Detalhes opcionais"
value={demand.description}
rows={2}
maxLength={4000}
onChange={(event) => updateDemand(demand.key, { description: event.target.value })}
/>
</div>
))}
</div>
<Button
type="button"
variant="outline"
className="min-h-11 w-fit"
onClick={addDemand}
disabled={demands.length >= MAX_ACTION_PLAN_DEMAND_DRAFTS}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Adicionar demanda
</Button>
{error ? <FieldError id={`${fieldId}-error`}>{error}</FieldError> : null}
</Field>
)
}

View file

@ -4,12 +4,10 @@ import { useActionState, useState } from 'react'
import Link from 'next/link'
import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox'
import { ActionPlanDemandFields } from '@/components/campaign/ActionPlanDemandFields'
import { ContactCombobox, type ContactComboboxOption } from '@/components/campaign/ContactCombobox'
import { ActionPlanTaskFields } from '@/components/campaign/ActionPlanTaskFields'
import {
RelationMultiSelect,
type RelationOption,
} from '@/components/campaign/RelationMultiSelect'
import { RelationMultiSelect, type RelationOption } from '@/components/campaign/RelationMultiSelect'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -29,7 +27,11 @@ import { Textarea } from '@/components/ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { Spinner } from '@/components/ui/Spinner'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan'
import {
actionPlanKindLabels,
actionPlanOriginLabels,
actionPlanStatusLabels,
} from '@/lib/schemas/actionPlan'
import type { ActionPlanLeadershipOption } from '@/utilities/actionPlanLeadershipOptions'
import type { ActionPlanFormViewModel } from '@/utilities/actionPlanViewModels'
import { fieldError } from '@/utilities/campaignFormFields'
@ -118,7 +120,9 @@ export const ActionPlanFormFields = ({
aria-invalid={Boolean(errorFor('title'))}
aria-describedby={errorFor('title') ? 'title-error' : undefined}
/>
{errorFor('title') ? <FieldError id="title-error">{errorFor('title')}</FieldError> : null}
{errorFor('title') ? (
<FieldError id="title-error">{errorFor('title')}</FieldError>
) : null}
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={Boolean(errorFor('kind'))}>
@ -139,7 +143,9 @@ export const ActionPlanFormFields = ({
</NativeSelectOption>
))}
</NativeSelect>
{errorFor('kind') ? <FieldError id="kind-error">{errorFor('kind')}</FieldError> : null}
{errorFor('kind') ? (
<FieldError id="kind-error">{errorFor('kind')}</FieldError>
) : null}
</Field>
<Field>
<FieldLegend>Status *</FieldLegend>
@ -162,12 +168,33 @@ export const ActionPlanFormFields = ({
</>
) : (
<FieldDescription>
Status atual: {actionPlanStatusLabels[plan?.status ?? ''] ?? plan?.status}. Use as
ações do detalhe para marcar como realizado ou cancelado.
Status atual: {actionPlanStatusLabels[plan?.status ?? ''] ?? plan?.status}. Use
as ações do detalhe para marcar como realizado ou cancelado.
</FieldDescription>
)}
</Field>
</div>
<Field data-invalid={Boolean(errorFor('origin'))}>
<FieldLabel htmlFor="origin">Origem da ação</FieldLabel>
<NativeSelect
id="origin"
name="origin"
defaultValue={plan?.origin ?? 'dado'}
className="w-full **:data-[slot=native-select]:min-h-11 sm:max-w-sm"
aria-invalid={Boolean(errorFor('origin'))}
>
{Object.entries(actionPlanOriginLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>
Registra se o plano nasceu da análise de dados, de um pedido de articulação ou de um
compromisso político.
</FieldDescription>
{errorFor('origin') ? <FieldError>{errorFor('origin')}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errorFor('description'))}>
<FieldLabel htmlFor="description">Descrição</FieldLabel>
<Textarea
@ -200,9 +227,7 @@ export const ActionPlanFormFields = ({
<CardContent>
<div className="grid gap-4 sm:grid-cols-3">
<Field data-invalid={Boolean(errorFor('startAt'))}>
<FieldLabel htmlFor="startAt">
Início {status !== 'rascunho' ? '*' : null}
</FieldLabel>
<FieldLabel htmlFor="startAt">Início {status !== 'rascunho' ? '*' : null}</FieldLabel>
<Input
id="startAt"
name="startAt"
@ -228,7 +253,9 @@ export const ActionPlanFormFields = ({
aria-invalid={Boolean(errorFor('endAt'))}
aria-describedby={errorFor('endAt') ? 'endAt-error' : undefined}
/>
{errorFor('endAt') ? <FieldError id="endAt-error">{errorFor('endAt')}</FieldError> : null}
{errorFor('endAt') ? (
<FieldError id="endAt-error">{errorFor('endAt')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(errorFor('deadline'))}>
<FieldLabel htmlFor="deadline">Prazo de conclusão</FieldLabel>
@ -273,7 +300,9 @@ export const ActionPlanFormFields = ({
</NativeSelectOption>
))}
</NativeSelect>
{errorFor('municipality') ? <FieldError id="municipality-error">{errorFor('municipality')}</FieldError> : null}
{errorFor('municipality') ? (
<FieldError id="municipality-error">{errorFor('municipality')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(errorFor('locality'))}>
<FieldLabel htmlFor="locality">Local (bairro, endereço ou referência)</FieldLabel>
@ -399,6 +428,15 @@ export const ActionPlanFormFields = ({
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Demandas</CardTitle>
</CardHeader>
<CardContent>
<ActionPlanDemandFields error={errorFor('demandsJson')} />
</CardContent>
</Card>
</FieldGroup>
)
}

View file

@ -1,11 +1,11 @@
'use client'
import { useActionState } from 'react'
import { useActionState, useState } from 'react'
import type { RelationOption } from '@/components/campaign/RelationMultiSelect'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
@ -13,17 +13,36 @@ import { Textarea } from '@/components/ui/textarea'
import { campaignDemandKindLabels, campaignDemandKinds } from '@/lib/schemas/campaignDemand'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
import type { ActionPlanRelationOption } from '@/utilities/campaignRelationOptions'
type DemandFormProps = {
municipalityOptions: RelationOption[]
actionPlanOptions: ActionPlanRelationOption[]
initialMunicipalityId?: number
initialActionPlanId?: number
formAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
}
export const DemandForm = ({ municipalityOptions, formAction }: DemandFormProps) => {
export const DemandForm = ({
municipalityOptions,
actionPlanOptions,
initialMunicipalityId,
initialActionPlanId,
formAction,
}: DemandFormProps) => {
const [state, submitAction, isPending] = useActionState(formAction, {})
const [municipalityId, setMunicipalityId] = useState(
initialMunicipalityId ? String(initialMunicipalityId) : '',
)
const [actionPlanId, setActionPlanId] = useState(
initialActionPlanId ? String(initialActionPlanId) : '',
)
const visibleActionPlans = municipalityId
? actionPlanOptions.filter((plan) => String(plan.municipalityId) === municipalityId)
: actionPlanOptions
return (
<form action={submitAction} className="flex max-w-2xl flex-col gap-4">
@ -63,7 +82,17 @@ export const DemandForm = ({ municipalityOptions, formAction }: DemandFormProps)
id="demand-municipality"
name="municipalityId"
required
defaultValue=""
value={municipalityId}
onChange={(event) => {
const nextMunicipalityId = event.target.value
setMunicipalityId(nextMunicipalityId)
const selectedPlan = actionPlanOptions.find(
(plan) => String(plan.id) === actionPlanId,
)
if (selectedPlan && String(selectedPlan.municipalityId) !== nextMunicipalityId) {
setActionPlanId('')
}
}}
className="min-h-11 w-full"
>
<NativeSelectOption value="" disabled>
@ -80,6 +109,34 @@ export const DemandForm = ({ municipalityOptions, formAction }: DemandFormProps)
) : null}
</Field>
</div>
<Field>
<FieldLabel htmlFor="demand-action-plan">Plano de ação relacionado</FieldLabel>
<NativeSelect
id="demand-action-plan"
name="actionPlanId"
value={actionPlanId}
onChange={(event) => {
const nextPlanId = event.target.value
setActionPlanId(nextPlanId)
const selectedPlan = actionPlanOptions.find((plan) => String(plan.id) === nextPlanId)
if (selectedPlan) setMunicipalityId(String(selectedPlan.municipalityId))
}}
className="min-h-11 w-full"
>
<NativeSelectOption value="">Nenhum plano</NativeSelectOption>
{visibleActionPlans.map((option) => (
<NativeSelectOption key={option.id} value={String(option.id)}>
{option.name}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>
Opcional. Ao escolher um plano, o município correspondente é preenchido automaticamente.
</FieldDescription>
{fieldError(state.fieldErrors, 'actionPlan') ? (
<FieldError>{fieldError(state.fieldErrors, 'actionPlan')}</FieldError>
) : null}
</Field>
<Field>
<FieldLabel htmlFor="demand-description">Detalhe a necessidade</FieldLabel>
<Textarea id="demand-description" name="description" rows={4} maxLength={4000} />

View file

@ -1,5 +1,8 @@
import { Badge } from '@/components/ui/Badge'
import { municipalityUpdateKindLabels } from '@/lib/schemas/municipalityUpdate'
import {
municipalitySignalTypeLabels,
municipalityUpdateKindLabels,
} from '@/lib/schemas/municipalityUpdate'
import type { MunicipalityUpdateViewModel } from '@/utilities/municipalityUpdatePageData'
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
@ -13,6 +16,7 @@ const kindVariant = {
semanal: 'secondary',
urgente: 'destructive',
nota: 'outline',
sinal: 'default',
} as const
export const MunicipalityUpdateFeed = ({ updates }: { updates: MunicipalityUpdateViewModel[] }) => {
@ -53,6 +57,13 @@ export const MunicipalityUpdateFeed = ({ updates }: { updates: MunicipalityUpdat
) : (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{update.body}</p>
)}
{update.kind === 'sinal' ? (
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
{update.signalType ? <Badge variant="outline">{municipalitySignalTypeLabels[update.signalType]}</Badge> : null}
{update.signalSource ? <span>Fonte: {update.signalSource}</span> : null}
{update.triangulated ? <span>Triangulado</span> : null}
</div>
) : null}
{update.activeVolunteers != null || update.newSupports != null ? (
<p className="text-sm text-muted-foreground">
{update.activeVolunteers != null

View file

@ -4,12 +4,15 @@ import { useActionState, useState } from 'react'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import {
municipalitySignalTypeLabels,
municipalitySignalTypeDescriptions,
municipalitySignalTypes,
municipalityUpdateKindLabels,
municipalityUpdateKinds,
type MunicipalityUpdateKind,
@ -25,7 +28,10 @@ type MunicipalityUpdateFormProps = {
) => Promise<CampaignFormActionState>
}
export const MunicipalityUpdateForm = ({ municipalityID, formAction }: MunicipalityUpdateFormProps) => {
export const MunicipalityUpdateForm = ({
municipalityID,
formAction,
}: MunicipalityUpdateFormProps) => {
const [state, submitAction, isPending] = useActionState(formAction, {})
const [kind, setKind] = useState<MunicipalityUpdateKind>('semanal')
@ -98,13 +104,66 @@ export const MunicipalityUpdateForm = ({ municipalityID, formAction }: Municipal
</div>
</>
) : (
<Field>
<FieldLabel htmlFor="municipality-update-body">Texto</FieldLabel>
<Textarea id="municipality-update-body" name="body" rows={4} maxLength={5000} />
{fieldError(state.fieldErrors, 'body') ? (
<FieldError>{fieldError(state.fieldErrors, 'body')}</FieldError>
<>
<Field>
<FieldLabel htmlFor="municipality-update-body">Texto</FieldLabel>
<Textarea
id="municipality-update-body"
name="body"
rows={kind === 'sinal' ? 2 : 4}
maxLength={5000}
/>
{fieldError(state.fieldErrors, 'body') ? (
<FieldError>{fieldError(state.fieldErrors, 'body')}</FieldError>
) : null}
</Field>
{kind === 'sinal' ? (
<>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="municipality-update-signal-type">Tipo do sinal</FieldLabel>
<NativeSelect
id="municipality-update-signal-type"
name="signalType"
defaultValue=""
required
>
<NativeSelectOption value="">Selecione</NativeSelectOption>
{municipalitySignalTypes.map((entry) => (
<NativeSelectOption key={entry} value={entry}>
{municipalitySignalTypeLabels[entry]}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>Escolha o fato político observado:</FieldDescription>
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
{municipalitySignalTypes.map((entry) => (
<li key={entry}>
<span className="font-medium text-foreground">
{municipalitySignalTypeLabels[entry]}:
</span>{' '}
{municipalitySignalTypeDescriptions[entry]}
</li>
))}
</ul>
</Field>
<Field>
<FieldLabel htmlFor="municipality-update-signal-source">Fonte</FieldLabel>
<Input
id="municipality-update-signal-source"
name="signalSource"
maxLength={160}
required
/>
</Field>
</div>
<label className="flex min-h-11 items-center gap-2 text-sm">
<input name="triangulated" type="checkbox" className="size-4" />
Triangulado — confirmado por mais de uma fonte independente
</label>
</>
) : null}
</Field>
</>
)}
{state.message && state.status !== 'success' ? (
<Alert variant="destructive">

View file

@ -1,5 +1,6 @@
import { z } from 'zod'
import { campaignDemandCreateSchema } from '@/lib/schemas/campaignDemandInput'
import {
positiveRelationshipId,
trimmedNullableText,
@ -30,6 +31,15 @@ export const actionPlanStatuses = [
'cancelado',
] as const
export const actionPlanOrigins = ['dado', 'pedido_broker', 'obrigacao_politica'] as const
export type ActionPlanOrigin = (typeof actionPlanOrigins)[number]
export const actionPlanOriginLabels: Record<ActionPlanOrigin, string> = {
dado: 'Baseado em dado',
pedido_broker: 'Pedido de broker',
obrigacao_politica: 'Obrigação política',
}
export const actionPlanKindLabels: Record<(typeof actionPlanKinds)[number], string> = {
caminhada: 'Caminhada',
comicio: 'Comício',
@ -55,6 +65,19 @@ export const actionPlanStatusLabels: Record<(typeof actionPlanStatuses)[number],
}
export const MAX_ACTION_PLAN_ORGANIZATIONS = 20
export const MAX_ACTION_PLAN_DEMAND_DRAFTS = 20
export const actionPlanDemandDraftSchema = campaignDemandCreateSchema.pick({
title: true,
kind: true,
description: true,
})
export const actionPlanDemandDraftsSchema = z
.array(actionPlanDemandDraftSchema)
.max(MAX_ACTION_PLAN_DEMAND_DRAFTS)
export type ActionPlanDemandDraft = z.input<typeof actionPlanDemandDraftSchema>
const taskSchema = z.object({
title: z.string().trim().min(1).max(200),
@ -79,6 +102,7 @@ const actionPlanFieldsSchema = z.object({
responsible: positiveRelationshipId.optional(),
leadership: positiveRelationshipId.optional(),
tasks: z.array(taskSchema).optional(),
origin: z.enum(actionPlanOrigins).optional(),
})
const validateSchedule = (

View file

@ -2,13 +2,39 @@ import { z } from 'zod'
import { positiveRelationshipId, trimmedOptionalText } from '@/lib/schemas/primitives'
export const municipalityUpdateKinds = ['semanal', 'urgente', 'nota'] as const
export const municipalityUpdateKinds = ['semanal', 'urgente', 'nota', 'sinal'] as const
export type MunicipalityUpdateKind = (typeof municipalityUpdateKinds)[number]
export const municipalityUpdateKindLabels: Record<MunicipalityUpdateKind, string> = {
semanal: 'Semanal',
urgente: 'Urgente',
nota: 'Nota',
sinal: 'Sinal',
}
export const municipalitySignalTypes = [
'invasao',
'esfriamento',
'visita_adversario',
'proposta_broker',
'outro',
] as const
export type MunicipalitySignalType = (typeof municipalitySignalTypes)[number]
export const municipalitySignalTypeLabels: Record<MunicipalitySignalType, string> = {
invasao: 'Invasão',
esfriamento: 'Esfriamento',
visita_adversario: 'Visita adversária',
proposta_broker: 'Proposta a broker',
outro: 'Outro',
}
export const municipalitySignalTypeDescriptions: Record<MunicipalitySignalType, string> = {
invasao: 'Adversário ocupando ou ganhando espaço antes dominado pela campanha.',
esfriamento: 'Queda de mobilização, compromisso ou resposta dos aliados locais.',
visita_adversario: 'Agenda ou presença de adversário com possível impacto no município.',
proposta_broker: 'Pedido, oferta ou negociação feita por um intermediário político.',
outro: 'Fato político relevante que não se encaixa nos tipos anteriores.',
}
const optionalCount = z.number().int().min(0).max(1_000_000).optional()
@ -23,6 +49,9 @@ export const municipalityUpdateCreateSchema = z
body: trimmedOptionalText(5000),
activeVolunteers: optionalCount,
newSupports: optionalCount,
signalType: z.enum(municipalitySignalTypes).optional(),
signalSource: trimmedOptionalText(160),
triangulated: z.boolean().optional(),
})
.superRefine((data, context) => {
if (data.kind === 'semanal') {
@ -50,6 +79,22 @@ export const municipalityUpdateCreateSchema = z
path: ['body'],
})
}
if (data.kind === 'sinal') {
if (!data.signalType) {
context.addIssue({
code: 'custom',
message: 'Informe o tipo do sinal.',
path: ['signalType'],
})
}
if (!data.signalSource) {
context.addIssue({
code: 'custom',
message: 'Informe a fonte do sinal.',
path: ['signalSource'],
})
}
}
})
export type MunicipalityUpdateCreateInput = z.input<typeof municipalityUpdateCreateSchema>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,174 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TYPE "public"."enum_municipality_update_kind" ADD VALUE IF NOT EXISTS 'sinal';
DO $$ BEGIN
CREATE TYPE "public"."enum_action_plan_origin" AS ENUM ('dado', 'pedido_broker', 'obrigacao_politica');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE "public"."enum_municipality_update_signal_type" AS ENUM (
'invasao',
'esfriamento',
'visita_adversario',
'proposta_broker',
'outro'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE "public"."enum_allocation_decision_outcome" AS ENUM ('aceita', 'descarta');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
ALTER TABLE "municipality_update"
ADD COLUMN IF NOT EXISTS "signal_type" "public"."enum_municipality_update_signal_type",
ADD COLUMN IF NOT EXISTS "signal_source" varchar,
ADD COLUMN IF NOT EXISTS "triangulated" boolean DEFAULT false NOT NULL;
ALTER TABLE "action_plan" ADD COLUMN IF NOT EXISTS "origin" "public"."enum_action_plan_origin";
CREATE TABLE IF NOT EXISTS "allocation_decision" (
"id" serial PRIMARY KEY NOT NULL,
"municipality_id" integer NOT NULL,
"pattern_id" varchar NOT NULL,
"outcome" "public"."enum_allocation_decision_outcome" NOT NULL,
"rationale" varchar NOT NULL,
"alternative_reading" varchar,
"snapshot" jsonb NOT NULL,
"decided_by_id" integer,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
DO $$ BEGIN
ALTER TABLE "allocation_decision" ADD CONSTRAINT "allocation_decision_municipality_id_municipality_id_fk"
FOREIGN KEY ("municipality_id") REFERENCES "public"."municipality"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "allocation_decision" ADD CONSTRAINT "allocation_decision_decided_by_id_campaign_user_id_fk"
FOREIGN KEY ("decided_by_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE INDEX IF NOT EXISTS "allocation_decision_municipality_idx" ON "allocation_decision" USING btree ("municipality_id");
CREATE INDEX IF NOT EXISTS "allocation_decision_pattern_idx" ON "allocation_decision" USING btree ("pattern_id");
CREATE INDEX IF NOT EXISTS "allocation_decision_decided_by_idx" ON "allocation_decision" USING btree ("decided_by_id");
CREATE INDEX IF NOT EXISTS "allocation_decision_updated_at_idx" ON "allocation_decision" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "allocation_decision_created_at_idx" ON "allocation_decision" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "allocation_decision_id" integer;
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels"
ADD CONSTRAINT "payload_locked_documents_rels_allocation_decision_fk"
FOREIGN KEY ("allocation_decision_id") REFERENCES "public"."allocation_decision"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_allocation_decision_id_idx"
ON "payload_locked_documents_rels" USING btree ("allocation_decision_id");
CREATE TABLE IF NOT EXISTS "_vote_pledge_v" (
"id" serial PRIMARY KEY NOT NULL,
"parent_id" integer,
"version_leadership_id" integer,
"version_municipality_id" integer,
"version_declared_votes" numeric,
"version_declared_at" timestamp(3) with time zone,
"version_declared_by_id" integer,
"version_estimated_votes_pessimistic" numeric,
"version_estimated_votes_central" numeric,
"version_estimated_votes_optimistic" numeric,
"version_estimate_note" varchar,
"version_estimated_by_id" integer,
"version_estimated_at" timestamp(3) with time zone,
"version_updated_at" timestamp(3) with time zone,
"version_created_at" timestamp(3) with time zone,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
DO $$ BEGIN
ALTER TABLE "_vote_pledge_v" ADD CONSTRAINT "_vote_pledge_v_parent_id_vote_pledge_id_fk"
FOREIGN KEY ("parent_id") REFERENCES "public"."vote_pledge"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "_vote_pledge_v" ADD CONSTRAINT "_vote_pledge_v_version_leadership_id_leadership_id_fk"
FOREIGN KEY ("version_leadership_id") REFERENCES "public"."leadership"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "_vote_pledge_v" ADD CONSTRAINT "_vote_pledge_v_version_municipality_id_municipality_id_fk"
FOREIGN KEY ("version_municipality_id") REFERENCES "public"."municipality"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "_vote_pledge_v" ADD CONSTRAINT "_vote_pledge_v_version_declared_by_id_campaign_user_id_fk"
FOREIGN KEY ("version_declared_by_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "_vote_pledge_v" ADD CONSTRAINT "_vote_pledge_v_version_estimated_by_id_campaign_user_id_fk"
FOREIGN KEY ("version_estimated_by_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_parent_idx" ON "_vote_pledge_v" USING btree ("parent_id");
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_version_version_leadership_idx" ON "_vote_pledge_v" USING btree ("version_leadership_id");
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_version_version_municipality_idx" ON "_vote_pledge_v" USING btree ("version_municipality_id");
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_version_version_declared_votes_idx" ON "_vote_pledge_v" USING btree ("version_declared_votes");
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_version_version_declared_by_idx" ON "_vote_pledge_v" USING btree ("version_declared_by_id");
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_version_version_estimated_by_idx" ON "_vote_pledge_v" USING btree ("version_estimated_by_id");
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_version_version_updated_at_idx" ON "_vote_pledge_v" USING btree ("version_updated_at");
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_version_version_created_at_idx" ON "_vote_pledge_v" USING btree ("version_created_at");
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_created_at_idx" ON "_vote_pledge_v" USING btree ("created_at");
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_updated_at_idx" ON "_vote_pledge_v" USING btree ("updated_at");
INSERT INTO "_vote_pledge_v" (
"parent_id",
"version_leadership_id",
"version_municipality_id",
"version_declared_votes",
"version_declared_at",
"version_declared_by_id",
"version_estimated_votes_pessimistic",
"version_estimated_votes_central",
"version_estimated_votes_optimistic",
"version_estimate_note",
"version_estimated_by_id",
"version_estimated_at",
"version_updated_at",
"version_created_at",
"created_at",
"updated_at"
)
SELECT
"id",
"leadership_id",
"municipality_id",
"declared_votes",
"declared_at",
"declared_by_id",
"estimated_votes_pessimistic",
"estimated_votes_central",
"estimated_votes_optimistic",
"estimate_note",
"estimated_by_id",
"estimated_at",
"updated_at",
"created_at",
"created_at",
"updated_at"
FROM "vote_pledge"
WHERE NOT EXISTS (
SELECT 1 FROM "_vote_pledge_v" AS versions WHERE versions."parent_id" = "vote_pledge"."id"
);
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE IF EXISTS "_vote_pledge_v";
ALTER TABLE "payload_locked_documents_rels"
DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_allocation_decision_fk";
DROP INDEX IF EXISTS "payload_locked_documents_rels_allocation_decision_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "allocation_decision_id";
DROP TABLE IF EXISTS "allocation_decision";
ALTER TABLE "action_plan" DROP COLUMN IF EXISTS "origin";
ALTER TABLE "municipality_update"
DROP COLUMN IF EXISTS "triangulated",
DROP COLUMN IF EXISTS "signal_source",
DROP COLUMN IF EXISTS "signal_type";
DROP TYPE IF EXISTS "public"."enum_action_plan_origin";
DROP TYPE IF EXISTS "public"."enum_municipality_update_signal_type";
DROP TYPE IF EXISTS "public"."enum_allocation_decision_outcome";
`)
}

View file

@ -25,6 +25,7 @@ import * as migration_20260723_201000_add_state_deputy from './20260723_201000_a
import * as migration_20260723_202000_reconcile_municipality_remodel from './20260723_202000_reconcile_municipality_remodel'
import * as migration_20260724_133600_drop_municipality_vote_goals from './20260724_133600_drop_municipality_vote_goals'
import * as migration_20260724_175500_contact_phone_optional from './20260724_175500_contact_phone_optional'
import * as migration_20260724_180000_add_campaign_foundation_records from './20260724_180000_add_campaign_foundation_records'
export const migrations = [
{
@ -162,4 +163,9 @@ export const migrations = [
down: migration_20260724_175500_contact_phone_optional.down,
name: '20260724_175500_contact_phone_optional',
},
{
up: migration_20260724_180000_add_campaign_foundation_records.up,
down: migration_20260724_180000_add_campaign_foundation_records.down,
name: '20260724_180000_add_campaign_foundation_records',
},
]

View file

@ -76,6 +76,7 @@ export interface Config {
organization: Organization;
stateDeputy: StateDeputy;
votePledge: VotePledge;
allocationDecision: AllocationDecision;
campaignDemand: CampaignDemand;
supporter: Supporter;
supporterImportBatch: SupporterImportBatch;
@ -110,6 +111,7 @@ export interface Config {
organization: OrganizationSelect<false> | OrganizationSelect<true>;
stateDeputy: StateDeputySelect<false> | StateDeputySelect<true>;
votePledge: VotePledgeSelect<false> | VotePledgeSelect<true>;
allocationDecision: AllocationDecisionSelect<false> | AllocationDecisionSelect<true>;
campaignDemand: CampaignDemandSelect<false> | CampaignDemandSelect<true>;
supporter: SupporterSelect<false> | SupporterSelect<true>;
supporterImportBatch: SupporterImportBatchSelect<false> | SupporterImportBatchSelect<true>;
@ -540,6 +542,33 @@ export interface VotePledge {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "allocationDecision".
*/
export interface AllocationDecision {
id: number;
municipality: number | Municipality;
patternId: string;
outcome: 'aceita' | 'descarta';
rationale: string;
alternativeReading?: string | null;
/**
* Somente os números e classificações usados no momento da decisão.
*/
snapshot:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
decidedBy?: (number | null) | CampaignUser;
updatedAt: string;
createdAt: string;
}
/**
* Necessidades operacionais da campanha. Custo e comprovantes são controle interno — não substituem a prestação de contas oficial (SPCE/TSE).
*
@ -613,6 +642,7 @@ export interface ActionPlan {
| 'outro';
status: 'rascunho' | 'planejado' | 'confirmado' | 'realizado' | 'cancelado';
description?: string | null;
origin?: ('dado' | 'pedido_broker' | 'obrigacao_politica') | null;
/**
* Marque quando o deputado Jorge Solla estiver presente na ação.
*/
@ -708,13 +738,19 @@ export interface MunicipalityUpdate {
id: number;
municipality: number | Municipality;
author: number | CampaignUser;
kind: 'semanal' | 'urgente' | 'nota';
kind: 'semanal' | 'urgente' | 'nota' | 'sinal';
worked?: string | null;
failed?: string | null;
needs?: string | null;
activeVolunteers?: number | null;
newSupports?: number | null;
body?: string | null;
signalType?: ('invasao' | 'esfriamento' | 'visita_adversario' | 'proposta_broker' | 'outro') | null;
signalSource?: string | null;
/**
* Confirmado por mais de uma fonte de campo.
*/
triangulated?: boolean | null;
updatedAt: string;
createdAt: string;
}
@ -1143,6 +1179,10 @@ export interface PayloadLockedDocument {
relationTo: 'votePledge';
value: number | VotePledge;
} | null)
| ({
relationTo: 'allocationDecision';
value: number | AllocationDecision;
} | null)
| ({
relationTo: 'campaignDemand';
value: number | CampaignDemand;
@ -1444,6 +1484,21 @@ export interface VotePledgeSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "allocationDecision_select".
*/
export interface AllocationDecisionSelect<T extends boolean = true> {
municipality?: T;
patternId?: T;
outcome?: T;
rationale?: T;
alternativeReading?: T;
snapshot?: T;
decidedBy?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignDemand_select".
@ -1522,6 +1577,9 @@ export interface MunicipalityUpdateSelect<T extends boolean = true> {
activeVolunteers?: T;
newSupports?: T;
body?: T;
signalType?: T;
signalSource?: T;
triangulated?: T;
updatedAt?: T;
createdAt?: T;
}
@ -1535,6 +1593,7 @@ export interface ActionPlanSelect<T extends boolean = true> {
kind?: T;
status?: T;
description?: T;
origin?: T;
deputyPresent?: T;
startAt?: T;
endAt?: T;
@ -2084,6 +2143,7 @@ export interface TaskCreateCollectionExport {
| 'organization'
| 'stateDeputy'
| 'votePledge'
| 'allocationDecision'
| 'campaignDemand'
| 'supporter'
| 'supporterImportBatch'

View file

@ -10,6 +10,7 @@ import { importExportPlugin } from '@payloadcms/plugin-import-export'
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
import { pt } from 'payload/i18n/pt'
import { ActionPlan } from './collections/ActionPlan'
import { AllocationDecision } from './collections/AllocationDecision'
import { CampaignDemand } from './collections/CampaignDemand'
import { CampaignInvite } from './collections/CampaignInvite'
import { CampaignUser } from './collections/CampaignUser'
@ -89,6 +90,7 @@ export default buildConfig({
Organization,
StateDeputy,
VotePledge,
AllocationDecision,
CampaignDemand,
Supporter,
SupporterImportBatch,

View file

@ -0,0 +1,5 @@
export {
canCreateMunicipalityUpdate as canCreateAllocationDecision,
canMutateMunicipalityUpdate as canMutateAllocationDecision,
canReadMunicipalityUpdate as canReadAllocationDecision,
} from '@/utilities/access/municipalityUpdates'

View file

@ -1,6 +1,6 @@
import type { Payload } from 'payload'
import type { ActionPlan, CampaignUser } from '@/payload-types'
import type { ActionPlan, CampaignDemand, CampaignUser } from '@/payload-types'
import type { ActionPlanDetailTab } from '@/utilities/actionPlanDetailTabUi'
import {
actionPlanMunicipalitySummary,
@ -11,6 +11,15 @@ import {
import { relationshipId } from '@/utilities/relationship'
import type { AccessibleActionPlanContext } from '@/utilities/actionPlanPageData'
type ActionPlanDemandSummary = {
id: number
title: string
slug: string
kind: CampaignDemand['kind']
status: CampaignDemand['status']
cost: number | null
}
const loadActionPlanUpdateAuthorNames = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
@ -59,7 +68,9 @@ const loadActionPlanMunicipalitySummaryById = async (
overrideAccess: true,
})
const municipality = result.docs[0]
return municipality ? { id: municipality.id, name: municipality.name, slug: municipality.slug } : null
return municipality
? { id: municipality.id, name: municipality.name, slug: municipality.slug }
: null
}
export const getActionPlanDetailPageData = async (
@ -67,16 +78,57 @@ export const getActionPlanDetailPageData = async (
user: CampaignUser,
context: AccessibleActionPlanContext,
activeTab: ActionPlanDetailTab,
): Promise<ActionPlanDetailViewModel> => {
): Promise<
ActionPlanDetailViewModel & {
demands: ActionPlanDemandSummary[]
demandCostTotal: number
}
> => {
const municipalityId = relationshipId(context.document.municipality)
const municipalitySummary =
actionPlanMunicipalitySummary(context.document.municipality) ??
(municipalityId ? await loadActionPlanMunicipalitySummaryById(payload, municipalityId) : null)
const authorNames =
const populatedMunicipalitySummary =
actionPlanMunicipalitySummary(context.document.municipality) ?? null
const municipalitySummaryPromise = populatedMunicipalitySummary
? Promise.resolve(populatedMunicipalitySummary)
: municipalityId
? loadActionPlanMunicipalitySummaryById(payload, municipalityId)
: Promise.resolve(null)
const authorNamesPromise =
activeTab === 'updates' && context.document.updates?.length
? await loadActionPlanUpdateAuthorNames(payload, user, context.document.updates)
: new Map<number, string>()
? loadActionPlanUpdateAuthorNames(payload, user, context.document.updates)
: Promise.resolve(new Map<number, string>())
const demandSummariesPromise: Promise<ActionPlanDemandSummary[]> =
activeTab === 'overview'
? payload
.find({
collection: 'campaignDemand',
where: { actionPlan: { equals: context.id } },
depth: 0,
pagination: false,
sort: 'createdAt',
select: { title: true, slug: true, kind: true, status: true, cost: true },
user,
overrideAccess: false,
})
.then(({ docs }) =>
docs.map((demand) => ({
id: demand.id,
title: demand.title,
slug: demand.slug,
kind: demand.kind,
status: demand.status,
cost: demand.cost ?? null,
})),
)
: Promise.resolve([])
const [municipalitySummary, authorNames, demands] = await Promise.all([
municipalitySummaryPromise,
authorNamesPromise,
demandSummariesPromise,
])
return toActionPlanDetailViewModel(context.document, activeTab, authorNames, municipalitySummary)
return {
...toActionPlanDetailViewModel(context.document, activeTab, authorNames, municipalitySummary),
demands,
demandCostTotal: demands.reduce((total, demand) => total + (demand.cost ?? 0), 0),
}
}

View file

@ -1,8 +1,12 @@
import 'server-only'
import {
actionPlanDemandDraftsSchema,
actionPlanKinds,
actionPlanOrigins,
actionPlanStatuses,
MAX_ACTION_PLAN_DEMAND_DRAFTS,
type ActionPlanDemandDraft,
type ActionPlanCreateInput,
type ActionPlanUpdateInput,
} from '@/lib/schemas/actionPlan'
@ -49,10 +53,7 @@ const parseTasksFormData = (formData: FormData): ParsedActionPlanTask[] => {
throw new FormDataBoundaryError('tasksJson', `Informe o título da tarefa ${index + 1}.`)
}
if (title.length > 200) {
throw new FormDataBoundaryError(
'tasksJson',
`O título da tarefa ${index + 1} é muito longo.`,
)
throw new FormDataBoundaryError('tasksJson', `O título da tarefa ${index + 1} é muito longo.`)
}
const responsible =
typeof record.responsible === 'number' &&
@ -76,6 +77,25 @@ const parseTasksFormData = (formData: FormData): ParsedActionPlanTask[] => {
})
}
const parseDemandDraftsFormData = (formData: FormData): ActionPlanDemandDraft[] => {
const raw = boundedJsonFormValue(formData, 'demandsJson', 80_000)
if (raw === undefined) return []
const parsed = actionPlanDemandDraftsSchema.safeParse(raw)
if (parsed.success) return parsed.data
const demandIndex = parsed.error.issues[0]?.path[0]
if (typeof demandIndex === 'number') {
throw new FormDataBoundaryError('demandsJson', `Demanda ${demandIndex + 1} inválida.`)
}
if (!Array.isArray(raw) || raw.length > MAX_ACTION_PLAN_DEMAND_DRAFTS) {
throw new FormDataBoundaryError(
'demandsJson',
`Informe no máximo ${MAX_ACTION_PLAN_DEMAND_DRAFTS} demandas válidas.`,
)
}
throw new FormDataBoundaryError('demandsJson', 'Lista de demandas inválida.')
}
const parseSharedActionPlanFormData = (formData: FormData) => {
const responsible = nullableRelationshipFormValue(formData, 'responsible')
const leadership = nullableRelationshipFormValue(formData, 'leadership')
@ -83,6 +103,7 @@ const parseSharedActionPlanFormData = (formData: FormData) => {
return {
title: optionalFormText(formData, 'title') ?? '',
kind: optionalFormText(formData, 'kind') as (typeof actionPlanKinds)[number],
origin: optionalFormText(formData, 'origin') as (typeof actionPlanOrigins)[number] | undefined,
status: optionalFormText(formData, 'status') as (typeof actionPlanStatuses)[number] | undefined,
description: optionalFormText(formData, 'description'),
deputyPresent: checkboxFormValue(formData, 'deputyPresent'),
@ -95,10 +116,17 @@ const parseSharedActionPlanFormData = (formData: FormData) => {
responsible,
leadership,
tasks: parseTasksFormData(formData),
demands: parseDemandDraftsFormData(formData),
}
}
export const parseActionPlanCreateFormData = (formData: FormData): ActionPlanCreateInput => {
export type ParsedActionPlanCreateFormData = ActionPlanCreateInput & {
demands: ActionPlanDemandDraft[]
}
export const parseActionPlanCreateFormData = (
formData: FormData,
): ParsedActionPlanCreateFormData => {
const shared = parseSharedActionPlanFormData(formData)
const advisorIds = repeatedRelationshipFormValues(formData, 'advisors')
@ -112,7 +140,13 @@ export const parseActionPlanCreateFormData = (formData: FormData): ActionPlanCre
}
}
export const parseActionPlanUpdateFormData = (formData: FormData): ActionPlanUpdateInput => {
export type ParsedActionPlanUpdateFormData = ActionPlanUpdateInput & {
demands: ActionPlanDemandDraft[]
}
export const parseActionPlanUpdateFormData = (
formData: FormData,
): ParsedActionPlanUpdateFormData => {
const shared = parseSharedActionPlanFormData(formData)
const advisorIds = repeatedRelationshipFormValues(formData, 'advisors')
// Checkboxes only submit when checked, so a hidden marker distinguishes

View file

@ -1,4 +1,5 @@
import type { ActionPlan, CampaignUser, Contact, Organization, Municipality } from '@/payload-types'
import type { ActionPlanOrigin } from '@/lib/schemas/actionPlan'
import type { ActionPlanDetailTab } from '@/utilities/actionPlanDetailTabUi'
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
@ -86,6 +87,7 @@ export const actionPlanFormSelect = {
title: true,
slug: true,
kind: true,
origin: true,
status: true,
description: true,
deputyPresent: true,
@ -114,6 +116,7 @@ export type ActionPlanFormViewModel = {
title: string
slug: string
kind: ActionPlan['kind']
origin: ActionPlanOrigin
status: ActionPlan['status']
description: string | null
deputyPresent: boolean
@ -130,15 +133,14 @@ export type ActionPlanFormViewModel = {
}
const relationshipIds = (value: unknown): number[] =>
(Array.isArray(value) ? value : [])
.map(relationshipId)
.filter((id): id is number => id !== null)
(Array.isArray(value) ? value : []).map(relationshipId).filter((id): id is number => id !== null)
export const toActionPlanFormViewModel = (plan: ActionPlan): ActionPlanFormViewModel => ({
id: plan.id,
title: plan.title,
slug: plan.slug,
kind: plan.kind,
origin: plan.origin ?? 'dado',
status: plan.status,
description: plan.description ?? null,
deputyPresent: Boolean(plan.deputyPresent),
@ -187,6 +189,7 @@ export const actionPlanDetailContextSelect = {
title: true,
slug: true,
kind: true,
origin: true,
status: true,
description: true,
deputyPresent: true,
@ -254,6 +257,7 @@ export type ActionPlanDetailViewModel = {
title: string
slug: string
kind: ActionPlan['kind']
origin: ActionPlanOrigin
status: ActionPlan['status']
description: string | null
deputyPresent: boolean
@ -297,8 +301,7 @@ const mapActionPlanUpdates = (
return {
id: update.id ?? null,
body: update.body,
authorName:
populatedName ?? (authorId ? (authorNamesById.get(authorId) ?? null) : null),
authorName: populatedName ?? (authorId ? (authorNamesById.get(authorId) ?? null) : null),
createdAt: update.createdAt ?? null,
}
})
@ -325,6 +328,7 @@ export const toActionPlanDetailViewModel = (
title: plan.title,
slug: plan.slug,
kind: plan.kind,
origin: plan.origin ?? 'dado',
status: plan.status,
description: plan.description ?? null,
deputyPresent: Boolean(plan.deputyPresent),
@ -356,8 +360,7 @@ export const toActionPlanDetailViewModel = (
total: plan.taskTotal ?? 0,
},
tasks: activeTab === 'tasks' ? mapActionPlanTasks(plan) : [],
updates:
activeTab === 'updates' ? mapActionPlanUpdates(plan, authorNamesById) : [],
updates: activeTab === 'updates' ? mapActionPlanUpdates(plan, authorNamesById) : [],
result: mapActionPlanResult(plan),
createdByName: relationshipName(plan.createdBy),
updatedAt: plan.updatedAt,

View file

@ -68,6 +68,12 @@ export {
canUpdateVotePledge,
} from '@/utilities/access/votePledges'
export {
canCreateAllocationDecision,
canMutateAllocationDecision,
canReadAllocationDecision,
} from '@/utilities/access/allocationDecisions'
export {
canCreateMunicipalityUpdate,
canMutateMunicipalityUpdate,

View file

@ -2,9 +2,9 @@ import type { Payload } from 'payload'
import type { CampaignDemandKind, CampaignDemandStatus } from '@/lib/schemas/campaignDemand'
import { campaignDemandKinds, campaignDemandStatuses } from '@/lib/schemas/campaignDemand'
import type { CampaignDemand, CampaignUser } from '@/payload-types'
import type { ActionPlan, CampaignDemand, CampaignUser } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship'
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
export const demandPageSize = 25
@ -94,7 +94,10 @@ const resolveMunicipalityAndRequesterNames = async (
return {
municipalityBy: new Map(
municipalities.docs.map((municipality) => [municipality.id, { name: municipality.name, slug: municipality.slug }]),
municipalities.docs.map((municipality) => [
municipality.id,
{ name: municipality.name, slug: municipality.slug },
]),
),
requesterBy: new Map(
leaderships.docs.map((leadership) => {
@ -193,6 +196,7 @@ export type DemandDetailViewModel = DemandRowViewModel & {
createdAt: string | null
}>
canLeaderEdit: boolean
actionPlan: { title: string; slug: string } | null
}
export const loadDemandDetail = async (
@ -212,7 +216,9 @@ export const loadDemandDetail = async (
const demand = result.docs[0] as CampaignDemand | undefined
if (!demand) return null
const { municipalityBy, requesterBy } = await resolveMunicipalityAndRequesterNames(payload, [demand])
const { municipalityBy, requesterBy } = await resolveMunicipalityAndRequesterNames(payload, [
demand,
])
const row = toDemandRow(demand, municipalityBy, requesterBy)
const authorIDs = [
@ -263,5 +269,8 @@ export const loadDemandDetail = async (
}))
: [],
canLeaderEdit: user.role === 'leader' && demand.status === 'aberta',
actionPlan: isPopulatedRelationship<ActionPlan>(demand.actionPlan)
? { title: demand.actionPlan.title, slug: demand.actionPlan.slug }
: null,
}
}

View file

@ -2,6 +2,11 @@ import type { Payload } from 'payload'
import type { RelationOption } from '@/components/campaign/RelationMultiSelect'
import type { CampaignUser } from '@/payload-types'
import { relationshipId } from '@/utilities/relationship'
export type ActionPlanRelationOption = RelationOption & {
municipalityId: number
}
/** Municipalities the actor may operate on (coordinator: all 436; advisor: administered). */
export const loadMunicipalityOptions = async (
@ -40,6 +45,28 @@ export const loadOrganizationOptions = async (
return result.docs.map((organization) => ({ id: organization.id, name: organization.name }))
}
export const loadActionPlanOptions = async (
payload: Payload,
user: CampaignUser,
): Promise<ActionPlanRelationOption[]> => {
const result = await payload.find({
collection: 'actionPlan',
depth: 0,
limit: 0,
pagination: false,
sort: 'title',
select: { title: true, municipality: true },
where: {},
user,
overrideAccess: false,
})
return result.docs.flatMap((plan) => {
const municipalityId = relationshipId(plan.municipality)
return municipalityId ? [{ id: plan.id, name: plan.title, municipalityId }] : []
})
}
export const loadStateDeputyOptions = async (
payload: Payload,
user: CampaignUser,

View file

@ -1,6 +1,9 @@
import type { Payload } from 'payload'
import type { MunicipalityUpdateKind } from '@/lib/schemas/municipalityUpdate'
import type {
MunicipalitySignalType,
MunicipalityUpdateKind,
} from '@/lib/schemas/municipalityUpdate'
import { municipalityUpdateKinds } from '@/lib/schemas/municipalityUpdate'
import type { CampaignUser, MunicipalityUpdate } from '@/payload-types'
import { relationshipId } from '@/utilities/relationship'
@ -18,6 +21,9 @@ export type MunicipalityUpdateViewModel = {
body: string | null
activeVolunteers: number | null
newSupports: number | null
signalType: MunicipalitySignalType | null
signalSource: string | null
triangulated: boolean
}
export type MunicipalityUpdateFeedState = {
@ -104,6 +110,9 @@ export const loadMunicipalityUpdatesFeed = async (
body: doc.body ?? null,
activeVolunteers: doc.activeVolunteers ?? null,
newSupports: doc.newSupports ?? null,
signalType: doc.signalType ?? null,
signalSource: doc.signalSource ?? null,
triangulated: Boolean(doc.triangulated),
}
}),
totalDocs: result.totalDocs,
@ -117,4 +126,7 @@ export const loadMunicipalityUpdatesPreview = async (
user: CampaignUser,
municipalityID: number,
): Promise<MunicipalityUpdateViewModel[]> =>
(await loadMunicipalityUpdatesFeed(payload, user, municipalityID, { page: 1 })).updates.slice(0, 3)
(await loadMunicipalityUpdatesFeed(payload, user, municipalityID, { page: 1 })).updates.slice(
0,
3,
)

View file

@ -0,0 +1,63 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
test.describe('Planos de ação — registro-fundação', () => {
test.setTimeout(90_000)
test('registra origem, cria demandas vinculadas e exibe a explicação dos sinais', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const coordinator = await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenadora Geral'),
email: `${fixtures.value('coordinator')}@example.com`,
password,
role: 'coordinator',
},
depth: 0,
})
const municipality = await fixtures.claimMunicipality()
const planTitle = fixtures.value('Giro territorial')
const transportDemand = fixtures.value('Transporte para equipe')
const materialDemand = fixtures.value('Material de rua')
await campaign.login(page, coordinator.email!, password)
await page.goto(`${campaign.baseURL}/campanha/planos/novo`)
await page.getByLabel('Título *').fill(planTitle)
await page.getByLabel('Tipo de ação *').selectOption('caminhada')
await page.getByLabel('Origem da ação').selectOption('obrigacao_politica')
await page.getByLabel('Praça *').selectOption(String(municipality.id))
await page.getByRole('button', { name: 'Adicionar demanda' }).click()
await page.getByLabel('Título da demanda 1').fill(transportDemand)
await page.getByLabel('Tipo da demanda 1').selectOption('transporte')
await page.getByRole('button', { name: 'Adicionar demanda' }).click()
await page.getByLabel('Título da demanda 2').fill(materialDemand)
await page.getByLabel('Tipo da demanda 2').selectOption('material')
await page.getByRole('button', { name: 'Criar plano' }).click()
await expect(page).toHaveURL(/\/campanha\/planos\/[^/?]+$/)
await expect(page.getByRole('heading', { name: planTitle })).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('Obrigação política', { exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: transportDemand })).toBeVisible()
await expect(page.getByRole('link', { name: materialDemand })).toBeVisible()
await page.getByRole('link', { name: 'Adicionar demanda' }).click()
await expect(page.getByLabel('Plano de ação relacionado')).toHaveValue(/\d+/)
await expect(page.getByLabel('Município')).toHaveValue(String(municipality.id))
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=updates`)
await page.getByLabel('Tipo', { exact: true }).selectOption('sinal')
await expect(page.getByText('Invasão:', { exact: true })).toBeVisible()
await expect(
page.getByText('Adversário ocupando ou ganhando espaço antes dominado pela campanha.'),
).toBeVisible()
await expect(page.getByText('Esfriamento:', { exact: true })).toBeVisible()
await expect(page.getByText('Visita adversária:', { exact: true })).toBeVisible()
await expect(page.getByText('Proposta a broker:', { exact: true })).toBeVisible()
})
})

View file

@ -21,7 +21,9 @@ type OwnedCollection =
| 'leadership'
| 'votePledge'
| 'campaignDemand'
| 'allocationDecision'
| 'municipalityUpdate'
| 'actionPlan'
| 'campaignInvite'
| 'consent'
| 'supporter'
@ -30,7 +32,9 @@ const deletionOrder: OwnedCollection[] = [
'campaignInvite',
'votePledge',
'campaignDemand',
'allocationDecision',
'municipalityUpdate',
'actionPlan',
'leadership',
'supporter',
'organization',
@ -129,52 +133,62 @@ class CampaignE2EOwnership {
}
private async discoverOwnedRows(): Promise<void> {
const [users, campaignUsers, contacts, organizations, consents] = await Promise.all([
this.rootPayload.find({
collection: 'users',
where: { email: { contains: this.runID } },
depth: 0,
pagination: false,
}),
this.rootPayload.find({
collection: 'campaignUser',
where: {
or: [
{ name: { contains: this.runID } },
{ email: { contains: this.runID } },
{ username: { contains: this.runID } },
],
},
depth: 0,
pagination: false,
}),
this.rootPayload.find({
collection: 'contact',
where: {
or: [{ name: { contains: this.runID } }, { email: { contains: this.runID } }],
},
depth: 0,
pagination: false,
}),
this.rootPayload.find({
collection: 'organization',
where: {
or: [{ name: { contains: this.runID } }, { slug: { contains: this.runID } }],
},
depth: 0,
pagination: false,
}),
this.rootPayload.find({
collection: 'consent',
where: { key: { contains: this.runID } },
depth: 0,
pagination: false,
}),
])
const [users, campaignUsers, contacts, organizations, actionPlans, consents] =
await Promise.all([
this.rootPayload.find({
collection: 'users',
where: { email: { contains: this.runID } },
depth: 0,
pagination: false,
}),
this.rootPayload.find({
collection: 'campaignUser',
where: {
or: [
{ name: { contains: this.runID } },
{ email: { contains: this.runID } },
{ username: { contains: this.runID } },
],
},
depth: 0,
pagination: false,
}),
this.rootPayload.find({
collection: 'contact',
where: {
or: [{ name: { contains: this.runID } }, { email: { contains: this.runID } }],
},
depth: 0,
pagination: false,
}),
this.rootPayload.find({
collection: 'organization',
where: {
or: [{ name: { contains: this.runID } }, { slug: { contains: this.runID } }],
},
depth: 0,
pagination: false,
}),
this.rootPayload.find({
collection: 'actionPlan',
where: {
or: [{ title: { contains: this.runID } }, { slug: { contains: this.runID } }],
},
depth: 0,
pagination: false,
}),
this.rootPayload.find({
collection: 'consent',
where: { key: { contains: this.runID } },
depth: 0,
pagination: false,
}),
])
for (const user of users.docs) this.own('users', user.id)
for (const user of campaignUsers.docs) this.own('campaignUser', user.id)
for (const contact of contacts.docs) this.own('contact', contact.id)
for (const organization of organizations.docs) this.own('organization', organization.id)
for (const actionPlan of actionPlans.docs) this.own('actionPlan', actionPlan.id)
for (const consent of consents.docs) this.own('consent', consent.id)
const userIDs = this.ids('campaignUser')

View file

@ -33,6 +33,7 @@ type CampaignCollection =
| 'municipalityUpdate'
| 'votePledge'
| 'campaignDemand'
| 'allocationDecision'
| 'actionPlan'
| 'leadership'
| 'supporter'
@ -105,6 +106,7 @@ const emptyOwnedIDs = (): OwnedIDs => ({
municipalityUpdate: new Set(),
votePledge: new Set(),
campaignDemand: new Set(),
allocationDecision: new Set(),
actionPlan: new Set(),
leadership: new Set(),
supporter: new Set(),
@ -868,6 +870,7 @@ export class CampaignFixtures {
const lockedDocumentConditions = [
['action_plan_id', this.owned.actionPlan],
['allocation_decision_id', this.owned.allocationDecision],
['campaign_invite_id', this.owned.campaignInvite],
['campaign_user_id', this.owned.campaignUser],
['campaign_demand_id', this.owned.campaignDemand],
@ -961,6 +964,7 @@ export class CampaignFixtures {
'campaignInvite',
'votePledge',
'campaignDemand',
'allocationDecision',
'municipalityUpdate',
'actionPlan',
'leadership',

View file

@ -8,6 +8,7 @@ import {
} from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { createActionPlanRecord } from '@/app/(campaign)/campanha/actions/actionPlan'
import { actionPlanCreateSchema, actionPlanUpdateSchema } from '@/lib/schemas/actionPlan'
import config from '@/payload.config'
import {
@ -16,6 +17,7 @@ import {
canUpdateActionPlan,
getAccessibleLeadershipIds,
} from '@/utilities/campaignAccess'
import { parseActionPlanCreateFormData } from '@/utilities/actionPlanFormData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
@ -80,6 +82,144 @@ describe('action plan domain', () => {
expect(result.success).toBe(false)
})
it('keeps the recorded origin of a new action plan', () => {
const parsed = actionPlanCreateSchema.parse({
...validPlanInput(1),
origin: 'pedido_broker',
})
expect(parsed.origin).toBe('pedido_broker')
})
it('parses the origin selected in the action plan form', () => {
const formData = new FormData()
formData.set('title', 'Caminhada no centro')
formData.set('kind', 'caminhada')
formData.set('status', 'rascunho')
formData.set('municipality', '1')
formData.set('origin', 'obrigacao_politica')
formData.set('tasksJson', '[]')
expect(parseActionPlanCreateFormData(formData).origin).toBe('obrigacao_politica')
})
it('parses several demand drafts from the action plan form', () => {
const formData = new FormData()
formData.set('title', 'Caminhada no centro')
formData.set('kind', 'caminhada')
formData.set('status', 'rascunho')
formData.set('municipality', '1')
formData.set('tasksJson', '[]')
formData.set(
'demandsJson',
JSON.stringify([
{ title: 'Panfletos', kind: 'material' },
{ title: 'Van', kind: 'transporte', description: 'Levar a equipe.' },
]),
)
expect(parseActionPlanCreateFormData(formData).demands).toEqual([
{ title: 'Panfletos', kind: 'material' },
{ title: 'Van', kind: 'transporte', description: 'Levar a equipe.' },
])
})
it('creates several demands with the action plan in one workflow', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const plan = await createActionPlanRecord(
payload,
coordinator,
{
...validPlanInput(municipality.id),
title: fixtures.value('Plano com demandas'),
origin: 'dado',
},
[
{ title: fixtures.value('Panfletos'), kind: 'material' },
{
title: fixtures.value('Van para equipe'),
kind: 'transporte',
description: 'Transporte para o dia da atividade.',
},
],
)
fixtures.own('actionPlan', plan.id)
const demands = await payload.find({
collection: 'campaignDemand',
where: { actionPlan: { equals: plan.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
demands.docs.forEach((demand) => fixtures.own('campaignDemand', demand.id))
expect(demands.docs).toHaveLength(2)
expect(demands.docs.map((demand) => demand.title)).toEqual(
expect.arrayContaining([
expect.stringContaining('Panfletos'),
expect.stringContaining('Van para equipe'),
]),
)
expect(
demands.docs.every((demand) => {
const demandMunicipality =
typeof demand.municipality === 'number' ? demand.municipality : demand.municipality.id
return demandMunicipality === municipality.id
}),
).toBe(true)
})
it('rolls back the plan and every nested demand when one demand conflicts', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const duplicateTitle = fixtures.value('Demanda já existente')
await fixtures.createCampaignDemand({
title: duplicateTitle,
municipality: municipality.id,
createdBy: coordinator.id,
})
const planTitle = fixtures.value('Plano que deve reverter')
await expect(
createActionPlanRecord(
payload,
coordinator,
{
...validPlanInput(municipality.id),
title: planTitle,
},
[
{ title: fixtures.value('Primeira demanda temporária'), kind: 'material' },
{ title: duplicateTitle, kind: 'transporte' },
],
),
).rejects.toThrow()
const [plans, nestedDemands] = await Promise.all([
payload.find({
collection: 'actionPlan',
where: { title: { equals: planTitle } },
depth: 0,
pagination: false,
overrideAccess: true,
}),
payload.find({
collection: 'campaignDemand',
where: { title: { contains: 'Primeira demanda temporária' } },
depth: 0,
pagination: false,
overrideAccess: true,
}),
])
expect(plans.docs).toHaveLength(0)
expect(nestedDemands.docs).toHaveLength(0)
})
it('scopes create/read/update by campaign role', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')

View file

@ -0,0 +1,99 @@
// @vitest-environment node
import { getPayload, type Payload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('campaign allocation decisions', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('records an immutable ex-ante decision in the advisor municipality scope', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
const decision = await payload.create({
collection: 'allocationDecision',
data: {
municipality: municipality.id,
patternId: 'P3-cobertura-baixa',
outcome: 'aceita',
rationale: 'A cobertura dos compromissos está abaixo do esperado.',
snapshot: { coverage: 0.42, scenario: 'central' },
},
depth: 0,
user: advisor,
overrideAccess: false,
})
fixtures.own('allocationDecision', decision.id)
expect(
typeof decision.decidedBy === 'number' ? decision.decidedBy : decision.decidedBy?.id,
).toBe(advisor.id)
await expect(
payload.update({
collection: 'allocationDecision',
id: decision.id,
data: { rationale: 'Tentativa de reescrever a decisão.' },
user: advisor,
overrideAccess: false,
}),
).rejects.toThrow()
})
it('requires an alternative reading when a suggestion is discarded', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await expect(
payload.create({
collection: 'allocationDecision',
data: {
municipality: municipality.id,
patternId: 'P8-pressao-adversaria',
outcome: 'descarta',
rationale: 'A leitura automática não representa o contexto local.',
snapshot: { pressureRank: 2 },
},
user: coordinator,
overrideAccess: false,
}),
).rejects.toThrow('leitura alternativa')
})
it('denies allocation decisions to leaders', async () => {
const fixtures = campaignFixtures()
const leader = await fixtures.createCampaignUser('leader')
const municipality = await fixtures.getMunicipality()
await expect(
payload.create({
collection: 'allocationDecision',
data: {
municipality: municipality.id,
patternId: 'P1-fila',
outcome: 'aceita',
rationale: 'Tentativa fora do papel permitido.',
snapshot: { rank: 1 },
},
user: leader,
overrideAccess: false,
}),
).rejects.toThrow()
})
})

View file

@ -99,6 +99,23 @@ describe('campaign municipality update domain', () => {
body: ' ',
}).success,
).toBe(false)
expect(
municipalityUpdateCreateSchema.safeParse({
municipality: 1,
kind: 'sinal',
body: 'Ex-prefeito retirou o apoio na feira.',
signalType: 'esfriamento',
signalSource: 'Assessora local',
}).success,
).toBe(true)
expect(
municipalityUpdateCreateSchema.safeParse({
municipality: 1,
kind: 'sinal',
body: 'Visita adversária confirmada.',
signalSource: 'Equipe local',
}).success,
).toBe(false)
})
it('strips forged author and timestamps from input', () => {

View file

@ -61,6 +61,35 @@ describe('vote pledges (declared by staff, estimated by staff)', () => {
expect(updated.declaredVotes).toBe(220)
})
it('preserves the vote pledge trajectory in native Payload versions', async () => {
const { municipality, advisor, leadership } = await createEngagedLeadership()
const pledge = await declareVotesRecord(payload, advisor, {
municipality: municipality.id,
leadership: leadership.id,
declaredVotes: 80,
})
campaignFixtures().own('votePledge', pledge.id)
await declareVotesRecord(payload, advisor, {
municipality: municipality.id,
leadership: leadership.id,
declaredVotes: 125,
})
const versions = await payload.findVersions({
collection: 'votePledge',
where: { parent: { equals: pledge.id } },
depth: 0,
pagination: false,
sort: 'createdAt',
overrideAccess: true,
})
expect(versions.docs.map((version) => version.version.declaredVotes)).toEqual(
expect.arrayContaining([80, 125]),
)
})
it('rejects a declaration in a municipality the leadership is not linked to', async () => {
const { advisor, fixtures, leadership } = await createEngagedLeadership()
const otherMunicipality = await fixtures.getMunicipality()