diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc
index 2cc02536..e65eee22 100644
--- a/.cursor/rules/projects/nucleos-eleitorais.mdc
+++ b/.cursor/rules/projects/nucleos-eleitorais.mdc
@@ -10,6 +10,7 @@ alwaysApply: false
- **MVP Ciclo 1 entregue e enviado em `main` (2026-07-18):** ondas 1–8 (auth/RBAC, domínio+UI de Núcleo/Liderança/Inteligência/Atualizações/Convites, dashboard, hardening) + refactors de composição Next.js, primitivos compartilhados, locks PostgreSQL, fixtures E2E e migration consolidada `20260718_010733_consolidate_campaign_schema`.
- **Ciclo 2 entregue, mesclado e deployado (2026-07-18):** A1 território multi-município/bairro, A3 baseline TSE 2022 (modelo + import), B1 overview da lista, C1 compartilhar página e D1 PWA — detalhes no item datado abaixo.
- **A2 entregue e mesclado em `main` (2026-07-18):** cadastro estático município↔ZE (`bahiaTseZones`, TSE 2024) + chips opt-in no formulário (`NucleusTerritoryAndZonesFields` / `territorySuggestions`); sem migration. Plano: [`docs/plans/zonas-por-municipio.md`](../../../docs/plans/zonas-por-municipio.md).
+- **C3 Planos de Ação implementado (2026-07-18):** collection `actionPlan` + vertical `/campanha/planos` (lista com tabs Próximos/Todos/Realizados/Rascunhos, detalhe com tabs Visão geral/Tarefas/Atualizações, forms novo/editar), blocos "Próximos eventos" no overview de núcleos e no dashboard; `startAt` opcional só em rascunho; access por `coordinators`/`leadership`; migration `20260718_222832_add_action_plan`.
- Fluxos operacionais cobertos: login isolado, CRUD/arquivo de núcleos por slug, designação de coordenador (`geral`), CRM de lideranças, estimativas sugerir/confirmar com versão UUID, feed de atualizações, convites WhatsApp (`wa.me` + hash de uso único), dashboard com agregados só de núcleos ativos.
- Pendência operacional (não de código): Consent `lideranca-autopreenchimento` + lote jurídico LGPD antes de dados reais; smoke pós-deploy Vercel. Ver Onda 0 em [`docs/roadmap.md`](../../../docs/roadmap.md).
- Em 2026-07-17, a listagem de Núcleos passou a aplicar filtros automaticamente pelo estado da URL, sem botão de confirmação, com comboboxes territoriais estritos compartilhados com o formulário.
diff --git a/AGENTS.md b/AGENTS.md
index 3a9ea519..c0fc5471 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -40,7 +40,7 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
3. **`Post`/`Tag` ship the news system, but there is still no `Pages` collection for institutional content.** Partly resolved: the `post`/`tag` collections back the news/publications system, and the home "Últimas notícias" list plus the `/[type]`, `/[type]/[category]`, and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (`HomePage` global still exposes only a single `image` field — the text lives in `src/app/(frontend)/(home)/page.tsx`), and there is no `Pages` collection yet for institutional content such as the bio and propostas.
4. **Campaign LGPD consent text is not yet configured in production.** Invites and self-service leadership flows fail closed until an admin creates `Consent.key = 'lideranca-autopreenchimento'` with counsel-approved copy (see deploy checklist).
-**Recently resolved (2026-07-18):** the `/campanha` nuclei MVP shipped (auth/RBAC, nuclei, leaderships, vote estimates, updates, WhatsApp invites, dashboard) with consolidated migration `20260718_010733_consolidate_campaign_schema`. Later the same day, cycle 2 was merged and deployed: multi-municipality/neighborhood territory arrays (migration `20260718_190559_territorio_multi_municipio_bairro`), TSE 2022 election baseline collections + `pnpm db:seed:tse` (migration `20260718_195854_add_election_results`), the filtered nuclei-list overview panel, the nucleus share dialog, and the installable `/campanha` PWA. Still on 2026-07-18, **A2** shipped: static Bahia municipality↔TSE zone map (`src/lib/bahiaTseZones.ts` from TSE 2024 `detalhe_votacao_munzona` BA) plus opt-in territory/zone suggestion chips in the nucleus form (`territorySuggestions`, `NucleusTerritoryAndZonesFields`) — no migration, no forced equality on save. Earlier (2026-07-15): local Postgres via `docker-compose.yml` (Postgres 17, `teqo_test`); Payload migrations baselined; dev/test database guards; `post`/`tag` news routes deployed (see "Posts & Tags" below).
+**Recently resolved (2026-07-18):** the `/campanha` nuclei MVP shipped (auth/RBAC, nuclei, leaderships, vote estimates, updates, WhatsApp invites, dashboard) with consolidated migration `20260718_010733_consolidate_campaign_schema`. Later the same day, cycle 2 was merged and deployed: multi-municipality/neighborhood territory arrays (migration `20260718_190559_territorio_multi_municipio_bairro`), TSE 2022 election baseline collections + `pnpm db:seed:tse` (migration `20260718_195854_add_election_results`), the filtered nuclei-list overview panel, the nucleus share dialog, and the installable `/campanha` PWA. Still on 2026-07-18, **A2** shipped: static Bahia municipality↔TSE zone map (`src/lib/bahiaTseZones.ts` from TSE 2024 `detalhe_votacao_munzona` BA) plus opt-in territory/zone suggestion chips in the nucleus form (`territorySuggestions`, `NucleusTerritoryAndZonesFields`) — no migration, no forced equality on save. Also the same day: C3 Planos de Ação (`actionPlan` + `/campanha/planos`, migration `20260718_222832_add_action_plan`, blocos "Próximos eventos"). Earlier (2026-07-15): local Postgres via `docker-compose.yml` (Postgres 17, `teqo_test`); Payload migrations baselined; dev/test database guards; `post`/`tag` news routes deployed (see "Posts & Tags" below).
## Posts & Tags (news / publications)
diff --git a/docs/plans/eventos-agenda-mobilizacao.md b/docs/plans/eventos-agenda-mobilizacao.md
index e76eb67a..6ab89a20 100644
--- a/docs/plans/eventos-agenda-mobilizacao.md
+++ b/docs/plans/eventos-agenda-mobilizacao.md
@@ -1,8 +1,8 @@
# Eventos / presença e agenda de mobilização
-Status: rascunho
-Atualizado em: 2026-07-17
-Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos", linha 54)
+Status: implementado
+Atualizado em: 2026-07-18
+Item do roadmap: [docs/roadmap.md](../roadmap.md) (Janela 2, ordem 9 — C3)
Responsável: —
## Referência visual (UX Pilot)
@@ -14,113 +14,50 @@ Dois designs cobrem este plano:
| Lista de planos (`/campanha/planos`) | [`Planos-de-Acao.png`](../design-refs/latest/Planos-de-Acao.png) · [`Planos-de-Acao.html`](../design-refs/latest/Planos-de-Acao.html) |
| Form novo plano | [`Novo-Plano-de-Acao.png`](../design-refs/latest/Novo-Plano-de-Acao.png) · [`Novo-Plano-de-Acao.html`](../design-refs/latest/Novo-Plano-de-Acao.html) |
-
-
-
Como usar:
-- **Lista — adotar:** tabs de janela/status ("Próximos" default, "Todos", "Realizados", "Rascunhos"), filtros "Tipo de ação" e "Território", cards com badge de `kind` (Caminhada, Reunião de apoio, Porta a porta, Comício, Panfletagem — cada tipo com cor própria clara), badge de `status` (Confirmado verde, Planejado azul, Rascunho outline, Realizado cinza), data/hora + município, chip do Território de Identidade, "Resp: {nome}" e barra de progresso de tarefas ("2/5 tarefas"). O card de rascunho com "Data a definir" esmaecido é um bom estado para `startAt` pendente em rascunho — mas atenção: o plano define `startAt` obrigatório; se rascunho puder ficar sem data, registrar em "Questões em aberto".
-- **Form — adotar:** seções "Informações básicas" (título, tipo, descrição opcional), "Data e horário" (data + início obrigatórios, término opcional com helper "Deixe em branco se a duração for indefinida"), "Território" (municípios multi-chip + território derivado automático — mesmo padrão de [`Formulario-Territorio.png`](../design-refs/latest/Formulario-Territorio.png), confirmando que o bloco nasce no modelo multi-município), "Pessoas responsáveis" (coordenadores com "+ Adicionar", responsável principal) e "Status inicial" (Planejado | Rascunho como radio cards).
-- **Falta desenhar (não bloqueia):** o detalhe do plano com tabs "Visão geral / Tarefas / Atualizações" não veio nesta leva — seguir o padrão `NucleusTabNav` do detalhe do núcleo; o checklist de tarefas pode reusar o formato de linhas do design da lista.
-- **Ajustar cores:** paleta antiga no HTML/PNG; implementar com tokens do tema `campaign`. A entrada "Agenda" do bottom nav só entra quando este domínio existir.
+- **Lista — adotar:** tabs de janela/status ("Próximos" default, "Todos", "Realizados", "Rascunhos"), filtros "Tipo de ação" e "Território", cards com badge de `kind`/`status`, data/hora + município, chip do Território de Identidade, "Resp: {nome}" e progresso de tarefas. Rascunho sem `startAt` mostra "Data a definir".
+- **Form — adotar:** seções "Informações básicas", "Data e horário", "Território" (reusa `NucleusTerritoryFields`), "Pessoas" (coordenadores, responsável, liderança vinculada) e status inicial (Planejado | Rascunho).
+- **Detalhe:** tabs Visão geral / Tarefas / Atualizações no padrão `NucleusTabNav` (sem design nesta leva).
+- **Ajustar cores:** tokens do tema `campaign`. Entrada "Planos" no nav (`src/components/campaign/nav.ts`).
## Contexto
-O roadmap lista "Eventos / presença e agenda de mobilização" como próximo ciclo da vertical `/campanha` (plano-arquitetura §4). Hoje a campanha só modela território (núcleo) e reporte (atualização); não há como planejar, escalar e acompanhar ações de campo — caminhadas, comícios, carreatas, panfletagens, reuniões de apoio, lançamentos, convenções, atos, entrevistas — nem ver o que vem a seguir.
-
-A pesquisa de jargão de marketing político brasileiro separa três conceitos que o item do roadmap agrupa:
-
-1. **Gestão de agenda do candidato** — o tempo do candidato é o recurso mais escasso; a agenda aloca esse tempo entre eventos de mobilização, mídia, encontros com lideranças, gravações e descanso. Agenda é instrumento estratégico, não lista de compromissos.
-2. **Tipos de ação de mobilização** — caminhadas, carreatas, comícios, panfletagens, porta a porta, reuniões de apoio (lideranças/categorias profissionais/grupos religiosos/sindicais), eventos programados (lançamentos, convenções, atos), entrevistas/aparições. Distinção entre alta intensidade (rua) e baixa intensidade (digital/WhatsApp).
-3. **Plano de ação de mobilização** — planejamento antecipado, coordenação centralizada, divisão clara de tarefas com responsáveis, prazos e KPIs, supervisor monitorando execução e conformidade eleitoral.
-
-A decisão de produto (2026-07-17) é modelar isso como **uma única entidade "Plano de Ação"** (`actionPlan`), onde cada documento é uma ação/evento calendarizável, com checklist de tarefas e feed de atualizações — enxuto o suficiente para um MVP e compatível com o bloco "Próximos eventos" já reservado no overview da lista de núcleos ([overview-lista-nucleos.md](overview-lista-nucleos.md)).
+Cada documento `actionPlan` é uma ação/evento calendarizável (caminhada, comício, etc.), com checklist de tarefas e feed de atualizações, alimentando o bloco "Próximos eventos" no overview de núcleos e no dashboard.
## Decisões travadas
-- **Granularidade: entidade única `actionPlan`.** Cada documento É uma ação/evento (caminhada, comício, etc.), não um guarda-chuva de várias ocorrências. Isso mantém o MVP enxuto (uma collection, uma migration, um CRUD) e torna a entidade calendar-addressable via `startAt`. (Decisão de produto 2026-07-17.)
-- **Território próprio, sem link a `electoralNucleus`.** O plano carrega seu próprio bloco de território, reusando as mesmas validações Bahia do núcleo. O escopo de acesso vem dos campos explícitos (`coordinators`, `leadership`), não de um núcleo vinculado. (Decisão de produto 2026-07-17.) Planos podem cobrir um território sem ser de um núcleo específico.
-- **O bloco de território nasce no modelo vigente do núcleo** (nota de sequenciamento 2026-07-17). Se [`territorio-multi-municipio-bairro.md`](territorio-multi-municipio-bairro.md) já tiver sido implementado (ordem recomendada no roadmap), `actionPlan` nasce com `regions[]`/`cities[]`/`neighborhoods[]` e as mesmas 6 regras de validação — nunca com o modelo single antigo, que criaria migração dupla. O bloco "Próximos eventos" no overview filtra por território com a mesma semântica da lista de núcleos (`contains` sobre arrays).
-- **`startAt` obrigatório + `endAt` opcional.** Sem data/hora de início não há agenda nem bloco "Próximos eventos". `deadline` (prazo de conclusão do plano) é distinto e opcional.
-- **Pessoas = `Contact` + `campaignUser`, nunca cadastro paralelo.** `coordinators` → `campaignUser` (hasMany, só `geral`/`coordenador`, mesmo `filterOptions` do núcleo). `responsible` → `Contact` (pessoa responsável em campo, não precisa ser usuário nem liderança). `leadership` → `leadership` (relação opcional à junção `Contact`↔núcleo, para marcar a liderança que conduz a ação). Honra os três campos propostos pelo produto sem inventar "pessoa" paralela (convenção AGENTS.md).
-- **`kind` como enum de tipos de mobilização.** `caminhada`, `comicio`, `carreatas`, `panfletagem`, `porta_a_porta`, `reuniao_apoio`, `lancamento`, `convencao`, `ato`, `entrevista`, `producao_conteudo`, `digital`, `outro`. Valores em pt-BR (são dados/labels, não identificadores).
-- **`status` como enum de ciclo de vida.** `rascunho`, `planejado`, `confirmado`, `realizado`, `cancelado`. Transições para `cancelado`/`realizado` restritas a `geral` ou coordenador atribuído (espelha `canManageNucleusLifecycle`).
-- **`slug` canônico imutável após criação.** Mesmo padrão de `setCanonicalNucleusSlug`: derivado de `title`, único, indexado, readOnly. Rotas em `/campanha/planos/[slug]` (segmento `planos` é dado/SEO em pt-BR, não identificador).
-- **`tasks` e `updates` como arrays no MVP.** `tasks`: `{ title, responsible→Contact, due, done, doneAt }`. `updates`: append-only `{ author→campaignUser, body, createdAt }`, com `author`/`createdAt`/`doneAt` derivados no servidor. Uma collection dedicada `actionUpdate` (espelhando `nucleusUpdate`, feed imutável paginado) fica como follow-up.
-- **Access control por campos explícitos.** `geral` vê/edita tudo; `coordenador` vê planos onde está em `coordinators`; `lideranca` vê planos onde `leadership` é uma das suas lideranças engajadas (IDs calculados e cacheados em `req.context`, mesmo padrão de `getAccessibleNucleusIds`). Escrita de `lideranca` restrita a toggle de `tasks.done` e append de `updates` no próprio plano (escopo estreito).
-- **Sem `Consent` para o plano.** Plano é dado interno de staff, não coleta de PII pública. Captura nominal de presentes/apoiadores (tocaria `Contact` + `Consent`) fica fora do MVP.
-- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`actionPlan`, `ActionPlan`, `ActionPlanForm`, `loadActionPlanListPageData`), labels admin e strings visíveis em pt-BR.
+- **Granularidade: entidade única `actionPlan`.**
+- **Território próprio, sem link a `electoralNucleus`.** Arrays `regions`/`cities`/`neighborhoods` + validação Bahia compartilhada (`campaignTerritoryValidation.ts`).
+- **Filtro de território na lista/overview usa `{ equals }`** sobre arrays (semântica A1), não `contains`.
+- **`startAt` opcional só em `rascunho`; obrigatório ao passar para `planejado`/`confirmado`/`realizado`/`cancelado`.** Card mostra "Data a definir" quando ausente. (Decisão de produto 2026-07-18.)
+- **Pessoas = `Contact` + `campaignUser`:** `coordinators` → `campaignUser`, `responsible` → `Contact`, `leadership` → `leadership` (opcional; escopo de leitura para `lideranca`).
+- **`kind` / `status` enums em pt-BR** (valores de dados).
+- **`slug` canônico imutável** a partir do `title`.
+- **`tasks` e `updates` como arrays no MVP** (append-only para updates; `doneAt`/`author`/`createdAt` derivados no servidor).
+- **Access:** `geral` tudo; `coordenador` onde está em `coordinators` (e pode criar, auto-incluído); `lideranca` onde `leadership` ∈ engajadas — escrita só toggle `tasks.done` + append `updates`.
+- **Transações:** escritas de domínio via `withPayloadTransaction` (`src/utilities/payloadTransaction.ts`).
+- **Sem `Consent`** (dado interno de staff).
-## Questões em aberto
+## Questões em aberto (resolvidas na implementação)
-- **`lideranca` edita tarefas?** Recomendação: sim, só toggle `done` (e append de `update`), sem editar título/datas/responsáveis. Confirmar com produto.
-- **`tasks.responsible` aceita qualquer `Contact` ou só acessíveis?** Recomendação: só contatos no escopo do ator (`getAccessibleContactIds`), espelhando o contato principal do núcleo. Definir com produto.
-- **Filtro de janela de data na lista.** Recomendação: filtro de "próximos" (`startAt >= now`), "passados" (`startAt < now`) e intervalo livre. Default: próximos, ordenado por `startAt` asc.
-- **Bloco "Próximos eventos" no dashboard `/campanha`.** O overview de `/campanha/nucleos` já reserva o bloco; o dashboard geral também ganha? Recomendação: sim, mesmo recorte (todos os planos não cancelados com `startAt >= now`), sem filtros de lista.
-- **Repetição/recorrência.** Eventos recorrentes (ex.: panfletagem toda sábado) ficam fora do MVP — cada ocorrência é um plano. Reavaliar quando o volume justificar.
-- **`actionUpdate` dedicado.** Migrar o array `updates` para collection separada (imutável, paginada, autor derivado) em follow-up se o feed crescer.
+- **`lideranca` edita tarefas?** Sim — só toggle `done` + append `update`.
+- **`tasks.responsible`:** contatos no escopo do ator (`getAccessibleContactIds` via busca do combobox).
+- **Filtro de janela:** tabs Próximos / Todos / Realizados / Rascunhos; default Próximos.
+- **Bloco no dashboard:** sim, sem filtros de lista.
+- **Recorrência / `actionUpdate` dedicado:** fora do MVP.
-## Abordagem proposta
+## Entrega (2026-07-18)
-```mermaid
-flowchart LR
- User["campaignUser autenticado"]
- Gen["geral: todos os planos"]
- Coord["coordenador: coordinators contains user.id"]
- Lid["lideranca: leadership in engagedLeaderships(user)"]
- Read["canReadActionPlan -> Where"]
- Plan["actionPlan title, kind, startAt/endAt, deadline, status coordinators, leadership, responsible territorio proprio, location, notes tasks[], updates[]"]
+- Collection `actionPlan` + migration `20260718_222832_add_action_plan`
+- Vertical `/campanha/planos` (lista, detalhe com tabs, novo/editar)
+- Blocos "Próximos eventos" no overview de núcleos e no dashboard
+- Access control + testes int `tests/int/campaignActionPlan.int.spec.ts`
- User --> Gen
- User --> Coord
- User --> Lid
- Gen --> Read
- Coord --> Read
- Lid --> Read
- Read --> Plan
-```
+## Revisões
-Componentes:
-
-- **`src/collections/ActionPlan.ts`** (novo): collection em admin group `Campanha`, `useAsTitle: title`. Campos conforme "Decisões travadas". Hooks `beforeValidate` (slug canonico + validação de territorio Bahia + `startAt < endAt`) e `beforeChange` (derivar `createdBy`, `tasks.doneAt`, `updates.author/createdAt`).
-- **`src/utilities/campaignAccess.ts`** (extensão): `canCreateActionPlan`, `canReadActionPlan` (retorna `Where` por papel), `canUpdateActionPlan`, `canDeleteActionPlan`, `canSetActionPlanSystemField`, `canSetActionPlanStatus`, e `getAccessibleLeadershipIds` (cache em `req.context`, espelha `getAccessibleNucleusIds`).
-- **`src/app/(campaign)/campanha/(app)/planos/page.tsx`**: lista paginada com filtros (kind, status, territorio, janela `startAt`), reusando `NucleusFilters`/`NucleusList`/`NucleusPagination` como referência de layout e `components/ui/*`.
-- **`src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx`**: detalhe com `NucleusTabNav`-style (Visão geral, Tarefas, Atualizações).
-- **`src/app/(campaign)/campanha/(app)/planos/novo/page.tsx`** e **`planos/[slug]/editar/page.tsx`**: form reusando `NucleusForm`/`NucleusTerritoryFields` como referência.
-- **`planos/formActions.ts`**, **`planos/[slug]/actionPlanFormActions.ts`**: server actions; escritas multi-collection (criar plano + update inicial; toggle tarefa + append update) envolvem `payload.db.beginTransaction/commitTransaction/rollbackTransaction` com `req: { transactionID }` (padrão AGENTS.md).
-- **`src/components/campaign/CampaignBottomNav.tsx`**: adicionar entrada "Planos".
-- **Bloco "Próximos eventos"**: ativar no overview de `/campanha/nucleos` (já desenhado em [overview-lista-nucleos.md](overview-lista-nucleos.md)) e no dashboard `/campanha`, consumindo `actionPlan` com `startAt >= now`, `status in [planejado, confirmado]`, ordenado por `startAt` asc, respeitando os mesmos filtros de territorio da página.
-
-## Ondas (lean, espelhando o ciclo de Núcleos)
-
-1. Domínio: collection `ActionPlan.ts`, hooks, access control em `campaignAccess.ts`, migration `add_action_plan`, `pnpm generate:types`, `pnpm generate:importmap`, `tsc --noEmit`, lint.
-2. Lista `/campanha/planos` + filtros + ativação do bloco "Próximos eventos" no overview de `/campanha/nucleos`.
-3. Detalhe `/campanha/planos/[slug]` com tabs (Visão geral, Tarefas, Atualizações).
-4. Forms novo/editar + server actions transacionais + toggle de tarefa + append de update.
-5. Dashboard "Próximos eventos" + E2E por papel + responsividade 360/390/768/1440 + hardening + Aikido por arquivo editado.
-
-## Dependências
-
-- **[`territorio-multi-municipio-bairro.md`](territorio-multi-municipio-bairro.md) — pré-requisito de sequenciamento** (2026-07-17): o bloco de território de `actionPlan` nasce no modelo de arrays. Ver "Decisões travadas".
-- Fora isso, nenhuma de outro plano. Reusa validações Bahia (`src/lib/bahiaTerritories.ts`, `src/lib/cities.ts`), access control existente (`src/utilities/campaignAccess.ts`), `slugify` (`src/utilities/slug.ts`), UI `components/ui/*` e padrões de `NucleusForm`/`NucleusFilters`/`NucleusList`/`NucleusTabNav`.
-- O bloco "Próximos eventos" do overview de `/campanha/nucleos` ([overview-lista-nucleos.md](overview-lista-nucleos.md)) é consumidor direto e só ganha UI quando esta collection existir.
+- 2026-07-18: auditoria pré-implementação — operador de território `equals` (não `contains`); transações via `withPayloadTransaction`; nav em `nav.ts`; validação de território extraída para helper compartilhado; `startAt` opcional só em rascunho.
## Não escopo
-- Collection dedicada `actionUpdate` (feed imutável paginado) — follow-up; MVP usa array.
-- Captura nominal de presentes/apoiadores (tocaria `Contact` + `Consent`) — fora do MVP.
-- Visão de calendário/mapa (depende do item mapa/PostGIS do roadmap).
-- Link a `electoralNucleus` (decisão: território próprio).
-- Recorrência de eventos — cada ocorrência é um plano no MVP.
-- Estimativa estatística de votos em eventos.
-- Notificações/lembretes de evento (item separado do roadmap, [notifications.md](notifications.md)).
-
-## Referências
-
-- `docs/roadmap.md` (linha 54)
-- `docs/plans/overview-lista-nucleos.md` — bloco "Próximos eventos" já desenhado
-- `src/collections/ElectoralNucleus.ts` — padrão de collection, hooks de slug/território, campos derivados
-- `src/utilities/campaignAccess.ts` — `getAccessibleNucleusIds`, `getFreshCampaignUser`, padrões de access por papel
-- `src/lib/bahiaTerritories.ts`, `src/lib/cities.ts` — validação de território Bahia
-- `src/utilities/slug.ts` — `slugify`
-- AGENTS.md — Campaign auth, naming conventions, transações multi-collection, workflow de migrations
+- Collection dedicada `actionUpdate`, captura nominal de presentes, calendário/mapa, link a `electoralNucleus`, recorrência, notificações (D2).
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 817ed62c..09732f8b 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta; C6 registrado — escala/DRY pós-C2 para depois do merge)
+Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta; C3 Planos de Ação implementado; C6 registrado — escala/DRY pós-C2)
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
@@ -112,7 +112,7 @@ flowchart TD
C1["C1 Compartilhar página ✓"]
C2["C2 Cadastro nominal de apoiadores"]
C6["C6 Escala e DRY pós-C2 (import/listas/forms)"]
- C3["C3 Eventos / agenda de mobilização"]
+ C3["C3 Eventos / agenda de mobilização ✓"]
C4["C4 Demandas"]
C5["C5 Operação dia D / GOTV (proposto, a validar)"]
end
@@ -146,7 +146,7 @@ flowchart TD
JUR -.chave de push.-> D2
```
-Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (engenharia — produção espera o jurídico), além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, C1 e D1 — ver "Ciclo 2" acima. Destravados por eles: A4 (A2/A3 + B1 prontos), C3/C4 (A1 pronto), C6 (após merge de C2) e D2 push (D1 pronto; falta a chave jurídica).
+Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (engenharia — produção espera o jurídico), além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, C1, C3 e D1 — ver "Ciclo 2" acima e C3 abaixo. Destravados por eles: A4 (A2/A3 + B1 prontos), C4 (A1 + C3 prontos), C6 (após merge de C2) e D2 push (D1 pronto; falta a chave jurídica).
### Sequência de execução por janela do calendário
@@ -168,7 +168,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (enge
| 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta 2026-07-18 — merge/deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3, C6 |
| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3 |
| 9 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6 |
-| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6 |
+| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6 |
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
diff --git a/src/app/(campaign)/campanha/(app)/nucleos/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/page.tsx
index dfa3cb76..370cdd0e 100644
--- a/src/app/(campaign)/campanha/(app)/nucleos/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/nucleos/page.tsx
@@ -45,7 +45,7 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) {
const now = new Date()
const [{ result, scope }, overview] = await Promise.all([
loadNucleusListPageData(payload, user, rawSearchParams),
- loadNucleusListOverviewData(payload, user, canonicalUrl.state),
+ loadNucleusListOverviewData(payload, user, canonicalUrl.state, now),
])
const resolvedUrl = resolveNucleusListUrl(rawSearchParams, result.totalPages)
diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx b/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx
new file mode 100644
index 00000000..c1a31051
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx
@@ -0,0 +1,60 @@
+import config from '@payload-config'
+import { getPayload } from 'payload'
+import { notFound, redirect } from 'next/navigation'
+
+import { searchActionPlanContactOptions } from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions'
+import { updateActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/planos/formActions'
+import { ActionPlanForm } from '@/components/campaign/ActionPlanForm'
+import { getActionPlanLeadershipOptions } from '@/utilities/actionPlanLeadershipOptions'
+import { getCampaignUser } from '@/utilities/campaignAuth'
+import {
+ ActionPlanNotFoundError,
+ getActionPlanEditPageData,
+} from '@/utilities/actionPlanPageData'
+import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions'
+
+type EditActionPlanPageProps = {
+ params: Promise<{ slug: string }>
+}
+
+export default async function EditActionPlanPage({ params }: EditActionPlanPageProps) {
+ const [{ slug }, user, payload] = await Promise.all([
+ params,
+ getCampaignUser(),
+ getPayload({ config }),
+ ])
+
+ if (!user) redirect('/campanha/login')
+ if (user.role === 'lideranca') redirect('/campanha/planos')
+ if (!slug) notFound()
+
+ const [view, coordinators, leadershipOptions] = await Promise.all([
+ getActionPlanEditPageData(payload, user, slug).catch((error) => {
+ if (error instanceof ActionPlanNotFoundError) notFound()
+ throw error
+ }),
+ getEligibleNucleusCoordinatorOptions(payload, user),
+ getActionPlanLeadershipOptions(payload, user),
+ ])
+
+ return (
+
+ )
+}
diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/lifecycleFormActions.ts b/src/app/(campaign)/campanha/(app)/planos/[slug]/lifecycleFormActions.ts
new file mode 100644
index 00000000..fff1b81a
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/lifecycleFormActions.ts
@@ -0,0 +1,59 @@
+'use server'
+
+import { redirect } from 'next/navigation'
+
+import {
+ cancelActionPlan,
+ markActionPlanRealized,
+} from '@/app/(campaign)/campanha/actions/actionPlan'
+import { requiredRelationshipFormValue } from '@/lib/formData'
+import type { ActionPlan } from '@/payload-types'
+
+export type ActionPlanLifecycleFormState = {
+ message?: string
+}
+
+const setActionPlanLifecycleFormAction = async (
+ formData: FormData,
+ mutate: (planId: number) => Promise>,
+ failureMessage: string,
+): Promise => {
+ let planId: number
+ try {
+ planId = requiredRelationshipFormValue(formData, 'id')
+ } catch {
+ return {
+ message: 'Não foi possível identificar o plano. Atualize a página e tente novamente.',
+ }
+ }
+
+ let planSlug: string
+ try {
+ const plan = await mutate(planId)
+ planSlug = plan.slug
+ } catch {
+ return { message: failureMessage }
+ }
+
+ redirect(`/campanha/planos/${planSlug}`)
+}
+
+export const cancelActionPlanFormAction = async (
+ _state: ActionPlanLifecycleFormState,
+ formData: FormData,
+): Promise =>
+ setActionPlanLifecycleFormAction(
+ formData,
+ cancelActionPlan,
+ 'Não foi possível cancelar este plano. Verifique seu acesso ou atualize a página e tente novamente.',
+ )
+
+export const markActionPlanRealizedFormAction = async (
+ _state: ActionPlanLifecycleFormState,
+ formData: FormData,
+): Promise =>
+ setActionPlanLifecycleFormAction(
+ formData,
+ markActionPlanRealized,
+ 'Não foi possível marcar este plano como realizado. Verifique seu acesso ou atualize a página e tente novamente.',
+ )
diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx
new file mode 100644
index 00000000..9e5e2a8a
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx
@@ -0,0 +1,192 @@
+import config from '@payload-config'
+import { PencilIcon, PlusIcon } from 'lucide-react'
+import Link from 'next/link'
+import { notFound, redirect } from 'next/navigation'
+import { getPayload } from 'payload'
+
+import {
+ CancelActionPlanDialog,
+ MarkActionPlanRealizedDialog,
+} from '@/components/campaign/ActionPlanLifecycleDialog'
+import { ActionPlanStatusBadge } from '@/components/campaign/ActionPlanStatusBadge'
+import { ActionPlanTabNav } from '@/components/campaign/ActionPlanTabNav'
+import { ActionPlanTaskChecklist } from '@/components/campaign/ActionPlanTaskChecklist'
+import { ActionPlanUpdateFeed } from '@/components/campaign/ActionPlanUpdateFeed'
+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 { getCampaignUser } from '@/utilities/campaignAuth'
+import {
+ ActionPlanNotFoundError,
+ getActionPlanDetailPageData,
+ resolveAccessibleActionPlanContext,
+} from '@/utilities/actionPlanPageData'
+import {
+ getActionPlanDetailTabRedirect,
+ resolveActionPlanDetailTab,
+} from '@/utilities/actionPlanDetailTabUi'
+import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
+
+type ActionPlanDetailPageProps = {
+ params: Promise<{ slug: string }>
+ searchParams: Promise>
+}
+
+export default async function ActionPlanDetailPage({
+ params,
+ searchParams,
+}: ActionPlanDetailPageProps) {
+ const [{ slug }, query, user, payload] = await Promise.all([
+ params,
+ searchParams,
+ getCampaignUser(),
+ getPayload({ config }),
+ ])
+
+ if (!user) redirect('/campanha/login')
+ if (!slug) notFound()
+
+ const activeTab = resolveActionPlanDetailTab(query)
+ const context = await resolveAccessibleActionPlanContext(payload, user, slug).catch((error) => {
+ if (error instanceof ActionPlanNotFoundError) notFound()
+ throw error
+ })
+ const view = getActionPlanDetailPageData(context)
+ const canonicalTabRedirect = getActionPlanDetailTabRedirect(view.slug, query)
+ if (canonicalTabRedirect) redirect(canonicalTabRedirect)
+
+ const canEdit = user.role === 'geral' || user.role === 'coordenador'
+ const canManageLifecycle =
+ canEdit && view.status !== 'realizado' && view.status !== 'cancelado'
+
+ return (
+
+
+
+
+
+
+ {actionPlanKindLabels[view.kind]}
+
+ {view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'}
+
+ {view.territoryLabel ? · {view.territoryLabel} : null}
+
+
+
+ {canEdit ? (
+
+
+
+ Editar
+
+
+ ) : null}
+
+
+
+ Nova atualização
+
+
+ {canManageLifecycle ? (
+ <>
+
+
+ >
+ ) : null}
+
+
+
+
+
+
+ {activeTab === 'overview' ? (
+
+
+
+ Detalhes
+
+
+ {view.description ? (
+ {view.description}
+ ) : (
+ Sem descrição.
+ )}
+
+
+
Início
+ {view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'A definir'}
+
+
+
Término
+ {view.endAt ? formatBahiaDateTimeLabel(view.endAt) : '—'}
+
+
+
Prazo de conclusão
+ {view.deadline ? formatBahiaDateTimeLabel(view.deadline) : '—'}
+
+
+
Responsável
+ {view.responsibleName ?? 'Não definido'}
+
+
+ {view.territoryNotes ? (
+
+
Observações do território
+
{view.territoryNotes}
+
+ ) : null}
+
+
+
+
+
+ Coordenação
+
+
+ {view.coordinators.length ? (
+
+ {view.coordinators.map((coordinator) => (
+ {coordinator.name}
+ ))}
+
+ ) : (
+ Nenhum coordenador vinculado.
+ )}
+
+ {view.tasks.filter((task) => task.done).length}/{view.tasks.length} tarefas
+ concluídas
+
+ {view.createdByName ? (
+ Criado por {view.createdByName}
+ ) : null}
+
+
+
+ ) : null}
+
+ {activeTab === 'tasks' ? (
+
+
+ Tarefas
+
+
+
+
+
+ ) : null}
+
+ {activeTab === 'updates' ? (
+
+ ) : null}
+
+ )
+}
diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/taskActions.ts b/src/app/(campaign)/campanha/(app)/planos/[slug]/taskActions.ts
new file mode 100644
index 00000000..c7c72f29
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/taskActions.ts
@@ -0,0 +1,24 @@
+'use server'
+
+import { toggleActionPlanTask } from '@/app/(campaign)/campanha/actions/actionPlan'
+
+export type ToggleActionPlanTaskResult = { ok: true } | { ok: false; message: string }
+
+export const toggleActionPlanTaskAction = async (
+ planId: number,
+ taskId: string,
+ done: boolean,
+): Promise => {
+ try {
+ await toggleActionPlanTask(planId, taskId, done)
+ return { ok: true }
+ } catch (error) {
+ return {
+ ok: false,
+ message:
+ error instanceof Error
+ ? error.message
+ : 'Não foi possível atualizar a tarefa. Atualize a página e tente novamente.',
+ }
+ }
+}
diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions.ts b/src/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions.ts
new file mode 100644
index 00000000..b45896d4
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions.ts
@@ -0,0 +1,44 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { ZodError } from 'zod'
+
+import { appendActionPlanUpdate } from '@/app/(campaign)/campanha/actions/actionPlan'
+import {
+ FormDataBoundaryError,
+ requiredFormText,
+ requiredRelationshipFormValue,
+ validationFieldErrors,
+} from '@/lib/formData'
+
+export type ActionPlanUpdateFormState = {
+ status?: 'success'
+ message?: string
+ fieldErrors?: Record
+}
+
+export const createActionPlanUpdateFormAction = async (
+ _state: ActionPlanUpdateFormState,
+ formData: FormData,
+): Promise => {
+ try {
+ const planId = requiredRelationshipFormValue(formData, 'planId')
+ const body = requiredFormText(formData, 'body')
+ await appendActionPlanUpdate(planId, body)
+ revalidatePath('/campanha/planos/[slug]', 'page')
+ return { status: 'success', message: 'Atualização registrada com sucesso.' }
+ } catch (error) {
+ if (error instanceof FormDataBoundaryError) {
+ return { fieldErrors: { [error.field]: [error.message] } }
+ }
+ if (error instanceof ZodError) {
+ return { fieldErrors: validationFieldErrors(error) }
+ }
+ return {
+ message:
+ error instanceof Error
+ ? error.message
+ : 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
+ }
+ }
+}
diff --git a/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts b/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts
new file mode 100644
index 00000000..dfb9176f
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts
@@ -0,0 +1,42 @@
+'use server'
+
+import config from '@payload-config'
+import { getPayload } from 'payload'
+
+import { getCampaignUser } from '@/utilities/campaignAuth'
+
+export type ActionPlanContactOption = {
+ id: number
+ name: string
+ phone: string
+}
+
+const CONTACT_OPTION_LIMIT = 20
+
+export const searchActionPlanContactOptions = async (
+ query: string,
+): Promise => {
+ const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
+ if (!user) throw new Error('Autenticação necessária.')
+
+ const trimmed = typeof query === 'string' ? query.trim().slice(0, 120) : ''
+ const digits = trimmed.replace(/\D/g, '')
+
+ const result = await payload.find({
+ collection: 'contact',
+ where: trimmed
+ ? {
+ or: [{ name: { contains: trimmed } }, { phone: { contains: digits || trimmed } }],
+ }
+ : {},
+ depth: 0,
+ limit: CONTACT_OPTION_LIMIT,
+ page: 1,
+ sort: 'name',
+ select: { name: true, phone: true },
+ user,
+ overrideAccess: false,
+ })
+
+ return result.docs.map(({ id, name, phone }) => ({ id, name, phone }))
+}
diff --git a/src/app/(campaign)/campanha/(app)/planos/formActions.ts b/src/app/(campaign)/campanha/(app)/planos/formActions.ts
new file mode 100644
index 00000000..0794eec6
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/planos/formActions.ts
@@ -0,0 +1,96 @@
+'use server'
+
+import config from '@payload-config'
+import { getPayload } from 'payload'
+import { redirect } from 'next/navigation'
+import { ZodError } from 'zod'
+
+import { createActionPlan, updateActionPlan } from '@/app/(campaign)/campanha/actions/actionPlan'
+import type { ActionPlanFormState } from '@/components/campaign/ActionPlanForm'
+import {
+ FormDataBoundaryError,
+ optionalFormText,
+ validationFieldErrors,
+} from '@/lib/formData'
+import { getCampaignUser } from '@/utilities/campaignAuth'
+import {
+ parseActionPlanCreateFormData,
+ parseActionPlanUpdateFormData,
+} from '@/utilities/actionPlanFormData'
+import { slugify } from '@/utilities/slug'
+
+const getActionError = (error: unknown): ActionPlanFormState => {
+ if (error instanceof FormDataBoundaryError) {
+ return { fieldErrors: { [error.field]: [error.message] } }
+ }
+ if (error instanceof ZodError) {
+ return { fieldErrors: validationFieldErrors(error) }
+ }
+ if (error instanceof Error && /já existe|unique|duplicate key/i.test(error.message)) {
+ return { fieldErrors: { title: ['Já existe um plano com este título.'] } }
+ }
+
+ return { message: 'Não foi possível concluir a ação. Atualize a página e tente novamente.' }
+}
+
+const existingActionPlanState = async (formData: FormData): Promise => {
+ const title = optionalFormText(formData, 'title')
+ const slug = title ? slugify(title) : ''
+ if (!slug) return null
+ const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
+ if (!user) return null
+
+ const result = await payload.find({
+ collection: 'actionPlan',
+ where: { slug: { equals: slug } },
+ depth: 0,
+ limit: 1,
+ pagination: false,
+ select: { slug: true },
+ user,
+ overrideAccess: false,
+ })
+ const existing = result.docs[0]
+ return existing
+ ? {
+ fieldErrors: { title: ['Já existe um plano com este título.'] },
+ existingHref: `/campanha/planos/${existing.slug}`,
+ submittedTitle: title,
+ }
+ : null
+}
+
+export const createActionPlanFormAction = async (
+ _state: ActionPlanFormState,
+ formData: FormData,
+): Promise => {
+ let planSlug: string
+
+ try {
+ const plan = await createActionPlan(parseActionPlanCreateFormData(formData))
+ planSlug = plan.slug
+ } catch (error) {
+ const existing = await existingActionPlanState(formData)
+ if (existing) return existing
+ return getActionError(error)
+ }
+
+ redirect(`/campanha/planos/${planSlug}`)
+}
+
+export const updateActionPlanFormAction = async (
+ _state: ActionPlanFormState,
+ formData: FormData,
+): Promise => {
+ let planSlug: string
+
+ try {
+ const input = parseActionPlanUpdateFormData(formData)
+ const plan = await updateActionPlan(input)
+ planSlug = plan.slug
+ } catch (error) {
+ return getActionError(error)
+ }
+
+ redirect(`/campanha/planos/${planSlug}`)
+}
diff --git a/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx b/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx
new file mode 100644
index 00000000..49c9aa60
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx
@@ -0,0 +1,42 @@
+import config from '@payload-config'
+import { getPayload } from 'payload'
+import { redirect } from 'next/navigation'
+
+import { searchActionPlanContactOptions } from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions'
+import { createActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/planos/formActions'
+import { ActionPlanForm } from '@/components/campaign/ActionPlanForm'
+import { getActionPlanLeadershipOptions } from '@/utilities/actionPlanLeadershipOptions'
+import { getCampaignUser } from '@/utilities/campaignAuth'
+import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions'
+
+export default async function NewActionPlanPage() {
+ const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
+
+ if (!user) redirect('/campanha/login')
+ if (user.role !== 'geral' && user.role !== 'coordenador') redirect('/campanha/planos')
+
+ const [coordinators, leadershipOptions] = await Promise.all([
+ getEligibleNucleusCoordinatorOptions(payload, user),
+ getActionPlanLeadershipOptions(payload, user),
+ ])
+
+ return (
+
+ )
+}
diff --git a/src/app/(campaign)/campanha/(app)/planos/page.tsx b/src/app/(campaign)/campanha/(app)/planos/page.tsx
new file mode 100644
index 00000000..b72ddecc
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/planos/page.tsx
@@ -0,0 +1,97 @@
+import config from '@payload-config'
+import { PlusIcon, SearchXIcon } from 'lucide-react'
+import Link from 'next/link'
+import { redirect } from 'next/navigation'
+import { getPayload } from 'payload'
+
+import { ActionPlanFilters } from '@/components/campaign/ActionPlanFilters'
+import { ActionPlanList } from '@/components/campaign/ActionPlanList'
+import { ActionPlanPagination } from '@/components/campaign/ActionPlanPagination'
+import { Button } from '@/components/ui/button'
+import {
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from '@/components/ui/Empty'
+import type { ActionPlan } from '@/payload-types'
+import { getCampaignUser } from '@/utilities/campaignAuth'
+import { loadActionPlanListPageData } from '@/utilities/actionPlanPageData'
+import { buildActionPlanFiltersKey, resolveActionPlanListUrl } from '@/utilities/actionPlanUi'
+import { toActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
+
+type ActionPlanListPageProps = {
+ searchParams: Promise>
+}
+
+export default async function ActionPlanListPage({ searchParams }: ActionPlanListPageProps) {
+ const rawSearchParams = await searchParams
+ const canonicalUrl = resolveActionPlanListUrl(rawSearchParams)
+ if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref)
+
+ const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
+ if (!user) return null
+
+ const now = new Date()
+ const { result, state } = await loadActionPlanListPageData(payload, user, rawSearchParams, now)
+ const resolvedUrl = resolveActionPlanListUrl(rawSearchParams, result.totalPages)
+ if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
+
+ const canCreate = user.role === 'geral' || user.role === 'coordenador'
+
+ return (
+
+
+
+
+
+ {result.docs.length ? (
+ <>
+
toActionPlanListViewModel(plan as ActionPlan))}
+ />
+
+
+ {result.totalDocs} {result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'}
+
+
+
+ >
+ ) : (
+
+
+
+
+
+ Nenhum plano encontrado
+
+ Ajuste os filtros ou a janela selecionada. Você só vê planos dentro do seu escopo.
+
+
+
+
+ Limpar filtros
+
+
+
+ )}
+
+ )
+}
diff --git a/src/app/(campaign)/campanha/actions/actionPlan.ts b/src/app/(campaign)/campanha/actions/actionPlan.ts
new file mode 100644
index 00000000..e4a43959
--- /dev/null
+++ b/src/app/(campaign)/campanha/actions/actionPlan.ts
@@ -0,0 +1,192 @@
+'use server'
+
+import type { Payload } from 'payload'
+
+import {
+ actionPlanCreateSchema,
+ actionPlanUpdateSchema,
+ type ActionPlanCreateInput,
+ type ActionPlanUpdateInput,
+} from '@/lib/schemas/actionPlan'
+import type { CampaignUser } from '@/payload-types'
+import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
+import { withPayloadTransaction } from '@/utilities/payloadTransaction'
+
+const MAX_ACTION_PLAN_UPDATE_BODY_LENGTH = 4000
+
+export const createActionPlanRecord = async (
+ payload: Payload,
+ actor: CampaignUser,
+ input: ActionPlanCreateInput,
+) => {
+ const data = actionPlanCreateSchema.parse(input)
+
+ return payload.create({
+ collection: 'actionPlan',
+ data: data as never,
+ depth: 0,
+ user: actor,
+ overrideAccess: false,
+ })
+}
+
+export const updateActionPlanRecord = async (
+ payload: Payload,
+ actor: CampaignUser,
+ input: ActionPlanUpdateInput,
+) => {
+ const { id, ...data } = actionPlanUpdateSchema.parse(input)
+
+ return withPayloadTransaction(
+ payload,
+ async ({ req }) => {
+ const currentActor = await reloadCampaignActor(payload, actor, req)
+
+ return payload.update({
+ collection: 'actionPlan',
+ id,
+ data,
+ depth: 0,
+ user: currentActor,
+ overrideAccess: false,
+ req,
+ })
+ },
+ { beginFailureMessage: 'Não foi possível iniciar a atualização do plano.' },
+ )
+}
+
+const setActionPlanLifecycleStatusRecord = async (
+ payload: Payload,
+ actor: CampaignUser,
+ id: number,
+ status: 'realizado' | 'cancelado',
+) =>
+ withPayloadTransaction(
+ payload,
+ async ({ req }) => {
+ const currentActor = await reloadCampaignActor(payload, actor, req)
+
+ return payload.update({
+ collection: 'actionPlan',
+ id,
+ data: { status },
+ depth: 0,
+ user: currentActor,
+ overrideAccess: false,
+ req,
+ })
+ },
+ { beginFailureMessage: 'Não foi possível atualizar o status do plano.' },
+ )
+
+export const toggleActionPlanTaskRecord = async (
+ payload: Payload,
+ actor: CampaignUser,
+ planId: number,
+ taskId: string,
+ done: boolean,
+) =>
+ withPayloadTransaction(
+ payload,
+ async ({ req }) => {
+ const currentActor = await reloadCampaignActor(payload, actor, req)
+ const plan = await payload.findByID({
+ collection: 'actionPlan',
+ id: planId,
+ depth: 0,
+ user: currentActor,
+ overrideAccess: false,
+ req,
+ })
+
+ const tasks = plan.tasks ?? []
+ const taskIndex = tasks.findIndex((task) => task.id === taskId)
+ if (taskIndex === -1) throw new Error('Tarefa não encontrada.')
+
+ const nextTasks = tasks.map((task, index) => (index === taskIndex ? { ...task, done } : task))
+
+ return payload.update({
+ collection: 'actionPlan',
+ id: planId,
+ data: { tasks: nextTasks },
+ depth: 0,
+ user: currentActor,
+ overrideAccess: false,
+ req,
+ })
+ },
+ { beginFailureMessage: 'Não foi possível atualizar a tarefa do plano.' },
+ )
+
+export const appendActionPlanUpdateRecord = async (
+ payload: Payload,
+ actor: CampaignUser,
+ planId: number,
+ body: string,
+) => {
+ const trimmedBody = body.trim()
+ if (!trimmedBody) throw new Error('Informe o texto da atualização.')
+ if (trimmedBody.length > MAX_ACTION_PLAN_UPDATE_BODY_LENGTH) {
+ throw new Error('Atualização muito longa. Reduza o texto e tente novamente.')
+ }
+
+ return withPayloadTransaction(
+ payload,
+ async ({ req }) => {
+ const currentActor = await reloadCampaignActor(payload, actor, req)
+ const plan = await payload.findByID({
+ collection: 'actionPlan',
+ id: planId,
+ depth: 0,
+ user: currentActor,
+ overrideAccess: false,
+ req,
+ })
+
+ const updates = plan.updates ?? []
+ const nextUpdates = [...updates, { body: trimmedBody }]
+
+ return payload.update({
+ collection: 'actionPlan',
+ id: planId,
+ data: { updates: nextUpdates },
+ depth: 0,
+ user: currentActor,
+ overrideAccess: false,
+ req,
+ })
+ },
+ { beginFailureMessage: 'Não foi possível registrar a atualização do plano.' },
+ )
+}
+
+export const createActionPlan = async (input: ActionPlanCreateInput) => {
+ const { payload, actor } = await getCampaignActionContext()
+ return createActionPlanRecord(payload, actor, input)
+}
+
+export const updateActionPlan = async (input: ActionPlanUpdateInput) => {
+ const { payload, actor } = await getCampaignActionContext()
+ return updateActionPlanRecord(payload, actor, input)
+}
+
+export const cancelActionPlan = async (id: number) => {
+ const { payload, actor } = await getCampaignActionContext()
+ return setActionPlanLifecycleStatusRecord(payload, actor, id, 'cancelado')
+}
+
+export const markActionPlanRealized = async (id: number) => {
+ const { payload, actor } = await getCampaignActionContext()
+ return setActionPlanLifecycleStatusRecord(payload, actor, id, 'realizado')
+}
+
+export const toggleActionPlanTask = async (planId: number, taskId: string, done: boolean) => {
+ const { payload, actor } = await getCampaignActionContext()
+ return toggleActionPlanTaskRecord(payload, actor, planId, taskId, done)
+}
+
+export const appendActionPlanUpdate = async (planId: number, body: string) => {
+ const { payload, actor } = await getCampaignActionContext()
+ return appendActionPlanUpdateRecord(payload, actor, planId, body)
+}
diff --git a/src/collections/ActionPlan.ts b/src/collections/ActionPlan.ts
new file mode 100644
index 00000000..a9debbac
--- /dev/null
+++ b/src/collections/ActionPlan.ts
@@ -0,0 +1,533 @@
+import type {
+ CollectionBeforeChangeHook,
+ CollectionBeforeValidateHook,
+ CollectionConfig,
+} from 'payload'
+import { APIError } from 'payload'
+
+import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
+import {
+ actionPlanKindLabels,
+ actionPlanKinds,
+ actionPlanStatusLabels,
+ actionPlanStatuses,
+} from '@/lib/schemas/actionPlan'
+import {
+ MAX_NUCLEUS_CITIES,
+ MAX_NUCLEUS_NEIGHBORHOODS,
+ MAX_NUCLEUS_REGIONS,
+} from '@/lib/schemas/nucleus'
+import {
+ canCreateActionPlan,
+ canCreateActionPlanCoordinators,
+ canDeleteActionPlan,
+ canManageActionPlanCoordinators,
+ canReadActionPlan,
+ canSetActionPlanStatus,
+ canSetActionPlanSystemField,
+ canUpdateActionPlan,
+} from '@/utilities/campaignAccess'
+import { createCampaignTerritoryValidationHook } from '@/utilities/campaignTerritoryValidation'
+import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorOptions'
+import { relationshipId } from '@/utilities/relationship'
+import { slugify } from '@/utilities/slug'
+
+const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
+
+const ACTION_PLAN_KIND_OPTIONS = actionPlanKinds.map((value) => ({
+ value,
+ label: actionPlanKindLabels[value],
+}))
+
+const ACTION_PLAN_STATUS_OPTIONS = actionPlanStatuses.map((value) => ({
+ value,
+ label: actionPlanStatusLabels[value],
+}))
+
+const actionPlanStaffFieldSnapshot = (doc: Record) => ({
+ title: trimmedText(doc.title),
+ slug: doc.slug ?? null,
+ kind: doc.kind ?? null,
+ status: doc.status ?? null,
+ description: trimmedText(doc.description),
+ startAt: doc.startAt ?? null,
+ endAt: doc.endAt ?? null,
+ deadline: doc.deadline ?? null,
+ regions: Array.isArray(doc.regions) ? doc.regions.map(String) : [],
+ cities: Array.isArray(doc.cities) ? doc.cities.map(String) : [],
+ neighborhoods: Array.isArray(doc.neighborhoods) ? doc.neighborhoods.map(String) : [],
+ locality: trimmedText(doc.locality),
+ territoryNotes: trimmedText(doc.territoryNotes),
+ coordinators: (Array.isArray(doc.coordinators) ? doc.coordinators : [])
+ .map(relationshipId)
+ .filter((id): id is number => id !== null),
+ responsible: relationshipId(doc.responsible),
+ leadership: relationshipId(doc.leadership),
+})
+
+const validateActionPlanTerritory = createCampaignTerritoryValidationHook({
+ entityLabel: 'plano',
+})
+
+const setCanonicalActionPlanSlug: CollectionBeforeValidateHook = ({
+ data,
+ operation,
+ originalDoc,
+}) => {
+ if (!data) return data
+ const title = trimmedText(data.title ?? originalDoc?.title)
+ const slug = slugify(title)
+ if (!slug) {
+ throw new APIError('Informe um título com letras ou números.', 400)
+ }
+ if (operation === 'update' && data.title !== undefined && title !== originalDoc?.title) {
+ throw new APIError('O título do plano não pode ser alterado após a criação.', 409)
+ }
+ data.title = title
+ data.slug = operation === 'create' ? slug : originalDoc?.slug
+ return data
+}
+
+const validateActionPlanSchedule: CollectionBeforeValidateHook = ({
+ data,
+ operation,
+ originalDoc,
+}) => {
+ if (!data) return data
+
+ const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
+ const status = typeof nextData.status === 'string' ? nextData.status : 'rascunho'
+ const startAt = nextData.startAt ?? null
+ const endAt = nextData.endAt ?? null
+
+ if (status !== 'rascunho' && !startAt) {
+ throw new APIError(
+ 'Informe a data e horário de início ao planejar ou confirmar o plano.',
+ 400,
+ )
+ }
+
+ if (startAt && endAt) {
+ const start = new Date(startAt as string)
+ const end = new Date(endAt as string)
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || !(start < end)) {
+ throw new APIError('O horário de término deve ser posterior ao de início.', 400)
+ }
+ }
+
+ return data
+}
+
+const validateActionPlanCoordinators: CollectionBeforeValidateHook = async ({ data, req }) => {
+ if (!data) return data
+ if (data.coordinators === undefined) return data
+
+ const coordinatorValues = data.coordinators
+ const coordinatorIDs = Array.isArray(coordinatorValues)
+ ? [...new Set(coordinatorValues.map(relationshipId).filter((id): id is number => id !== null))]
+ : []
+
+ if (coordinatorIDs.length === 0) return data
+
+ const eligibleCoordinators = await req.payload.find({
+ collection: 'campaignUser',
+ depth: 0,
+ pagination: false,
+ where: {
+ and: [{ id: { in: coordinatorIDs } }, eligibleNucleusCoordinatorWhere],
+ },
+ select: { name: true },
+ overrideAccess: true,
+ req,
+ })
+
+ if (eligibleCoordinators.docs.length !== coordinatorIDs.length) {
+ throw new APIError('Cada responsável deve ter papel de coordenação geral ou coordenador.', 400)
+ }
+
+ return data
+}
+
+const deriveActionPlanFields: CollectionBeforeChangeHook = ({ data, operation, originalDoc, req }) => {
+ if (!data) return data
+
+ if (operation === 'create' && req.user?.collection === 'campaignUser') {
+ data.createdBy = req.user.id
+ if (
+ req.user.role === 'coordenador' &&
+ (data.coordinators === undefined ||
+ (Array.isArray(data.coordinators) && data.coordinators.length === 0))
+ ) {
+ data.coordinators = [req.user.id]
+ }
+ }
+
+ if (Array.isArray(data.tasks)) {
+ const previousTasks = Array.isArray(originalDoc?.tasks) ? originalDoc.tasks : []
+ data.tasks = data.tasks.map((task: Record, index: number) => {
+ const previous = previousTasks[index] as Record | undefined
+ const done = Boolean(task.done)
+ const previousDone = Boolean(previous?.done)
+ let doneAt = task.doneAt ?? previous?.doneAt ?? null
+ if (done && !previousDone) {
+ doneAt = new Date().toISOString()
+ } else if (!done) {
+ doneAt = null
+ }
+ return { ...task, done, doneAt }
+ })
+ }
+
+ if (Array.isArray(data.updates)) {
+ const previousCount = Array.isArray(originalDoc?.updates) ? originalDoc.updates.length : 0
+ data.updates = data.updates.map((update: Record, index: number) => {
+ if (index < previousCount) {
+ const previous = (originalDoc?.updates as Record[])[index]
+ return previous
+ }
+ return {
+ body: trimmedText(update.body),
+ author:
+ req.user?.collection === 'campaignUser'
+ ? req.user.id
+ : (update.author ?? null),
+ createdAt: new Date().toISOString(),
+ }
+ })
+ }
+
+ if (operation === 'update' && req.user?.collection === 'campaignUser' && req.user.role === 'lideranca') {
+ const previous = (originalDoc ?? {}) as Record
+ const merged = { ...previous, ...(data as Record) }
+ if (
+ JSON.stringify(actionPlanStaffFieldSnapshot(previous)) !==
+ JSON.stringify(actionPlanStaffFieldSnapshot(merged))
+ ) {
+ throw new APIError(
+ 'Lideranças só podem marcar tarefas e registrar atualizações no plano.',
+ 403,
+ )
+ }
+
+ if (Array.isArray(data.tasks) && Array.isArray(originalDoc?.tasks)) {
+ const previousTasks = originalDoc.tasks as Record[]
+ if (data.tasks.length !== previousTasks.length) {
+ throw new APIError('Lideranças não podem adicionar ou remover tarefas.', 403)
+ }
+ for (const [index, task] of data.tasks.entries()) {
+ const previousTask = previousTasks[index]
+ const taskRecord = task as Record
+ if (
+ trimmedText(taskRecord.title) !== trimmedText(previousTask.title) ||
+ relationshipId(taskRecord.responsible) !== relationshipId(previousTask.responsible) ||
+ (taskRecord.due ?? null) !== (previousTask.due ?? null)
+ ) {
+ throw new APIError(
+ 'Lideranças só podem marcar tarefas como concluídas.',
+ 403,
+ )
+ }
+ }
+ } else if (Array.isArray(data.tasks) && !Array.isArray(originalDoc?.tasks)) {
+ throw new APIError('Lideranças não podem adicionar ou remover tarefas.', 403)
+ }
+ }
+
+ return data
+}
+
+export const ActionPlan: CollectionConfig = {
+ slug: 'actionPlan',
+ labels: {
+ singular: 'Plano de ação',
+ plural: 'Planos de ação',
+ },
+ admin: {
+ group: 'Campanha',
+ useAsTitle: 'title',
+ defaultColumns: ['title', 'kind', 'status', 'startAt', 'updatedAt'],
+ },
+ access: {
+ create: canCreateActionPlan,
+ read: canReadActionPlan,
+ update: canUpdateActionPlan,
+ delete: canDeleteActionPlan,
+ },
+ hooks: {
+ beforeValidate: [
+ setCanonicalActionPlanSlug,
+ validateActionPlanTerritory,
+ validateActionPlanSchedule,
+ validateActionPlanCoordinators,
+ ],
+ beforeChange: [deriveActionPlanFields],
+ },
+ fields: [
+ {
+ name: 'title',
+ type: 'text',
+ label: 'Título',
+ required: true,
+ minLength: 2,
+ maxLength: 160,
+ index: true,
+ },
+ {
+ name: 'slug',
+ type: 'text',
+ label: 'Slug',
+ required: true,
+ unique: true,
+ index: true,
+ admin: {
+ readOnly: true,
+ },
+ access: {
+ create: canSetActionPlanSystemField,
+ update: canSetActionPlanSystemField,
+ },
+ },
+ {
+ name: 'kind',
+ type: 'select',
+ label: 'Tipo de ação',
+ required: true,
+ index: true,
+ options: [...ACTION_PLAN_KIND_OPTIONS],
+ },
+ {
+ name: 'status',
+ type: 'select',
+ label: 'Status',
+ required: true,
+ defaultValue: 'rascunho',
+ index: true,
+ access: {
+ create: canSetActionPlanStatus,
+ update: canSetActionPlanStatus,
+ },
+ options: [...ACTION_PLAN_STATUS_OPTIONS],
+ },
+ {
+ name: 'description',
+ type: 'textarea',
+ label: 'Descrição',
+ maxLength: 4000,
+ },
+ {
+ name: 'startAt',
+ type: 'date',
+ label: 'Início',
+ index: true,
+ admin: {
+ date: {
+ pickerAppearance: 'dayAndTime',
+ },
+ },
+ },
+ {
+ name: 'endAt',
+ type: 'date',
+ label: 'Término',
+ admin: {
+ date: {
+ pickerAppearance: 'dayAndTime',
+ },
+ },
+ },
+ {
+ name: 'deadline',
+ type: 'date',
+ label: 'Prazo de conclusão',
+ admin: {
+ date: {
+ pickerAppearance: 'dayAndTime',
+ },
+ },
+ },
+ {
+ name: 'regions',
+ type: 'text',
+ label: 'Territórios de identidade',
+ hasMany: true,
+ index: true,
+ maxRows: MAX_NUCLEUS_REGIONS,
+ validate: (value: unknown) => {
+ if (value === null || value === undefined) return true
+ if (!Array.isArray(value)) {
+ return 'Selecione um território de identidade válido da Bahia.'
+ }
+ return value.every(
+ (item) => typeof item === 'string' && isBahiaIdentityTerritory(item.trim()),
+ )
+ ? true
+ : 'Selecione um território de identidade válido da Bahia.'
+ },
+ },
+ {
+ name: 'cities',
+ type: 'text',
+ label: 'Municípios',
+ hasMany: true,
+ maxLength: 120,
+ index: true,
+ maxRows: MAX_NUCLEUS_CITIES,
+ },
+ {
+ name: 'neighborhoods',
+ type: 'text',
+ label: 'Bairros',
+ hasMany: true,
+ maxLength: 160,
+ maxRows: MAX_NUCLEUS_NEIGHBORHOODS,
+ },
+ {
+ name: 'locality',
+ type: 'text',
+ label: 'Localidade',
+ maxLength: 160,
+ },
+ {
+ name: 'territoryNotes',
+ type: 'textarea',
+ label: 'Observações do território',
+ maxLength: 2000,
+ },
+ {
+ name: 'coordinators',
+ type: 'relationship',
+ relationTo: 'campaignUser',
+ label: 'Coordenadores',
+ hasMany: true,
+ index: true,
+ access: {
+ create: canCreateActionPlanCoordinators,
+ update: canManageActionPlanCoordinators,
+ },
+ filterOptions: eligibleNucleusCoordinatorWhere,
+ },
+ {
+ name: 'responsible',
+ type: 'relationship',
+ relationTo: 'contact',
+ label: 'Responsável',
+ index: true,
+ },
+ {
+ name: 'leadership',
+ type: 'relationship',
+ relationTo: 'leadership',
+ label: 'Liderança',
+ index: true,
+ },
+ {
+ name: 'tasks',
+ type: 'array',
+ label: 'Tarefas',
+ labels: {
+ singular: 'Tarefa',
+ plural: 'Tarefas',
+ },
+ fields: [
+ {
+ name: 'title',
+ type: 'text',
+ label: 'Título',
+ required: true,
+ maxLength: 200,
+ },
+ {
+ name: 'responsible',
+ type: 'relationship',
+ relationTo: 'contact',
+ label: 'Responsável',
+ },
+ {
+ name: 'due',
+ type: 'date',
+ label: 'Prazo',
+ admin: {
+ date: {
+ pickerAppearance: 'dayAndTime',
+ },
+ },
+ },
+ {
+ name: 'done',
+ type: 'checkbox',
+ label: 'Concluída',
+ defaultValue: false,
+ },
+ {
+ name: 'doneAt',
+ type: 'date',
+ label: 'Concluída em',
+ admin: {
+ readOnly: true,
+ },
+ access: {
+ create: canSetActionPlanSystemField,
+ update: canSetActionPlanSystemField,
+ },
+ },
+ ],
+ },
+ {
+ name: 'updates',
+ type: 'array',
+ label: 'Atualizações',
+ labels: {
+ singular: 'Atualização',
+ plural: 'Atualizações',
+ },
+ fields: [
+ {
+ name: 'body',
+ type: 'textarea',
+ label: 'Texto',
+ required: true,
+ maxLength: 4000,
+ },
+ {
+ name: 'author',
+ type: 'relationship',
+ relationTo: 'campaignUser',
+ label: 'Autor',
+ admin: {
+ readOnly: true,
+ },
+ access: {
+ create: canSetActionPlanSystemField,
+ update: canSetActionPlanSystemField,
+ },
+ },
+ {
+ name: 'createdAt',
+ type: 'date',
+ label: 'Criada em',
+ admin: {
+ readOnly: true,
+ },
+ access: {
+ create: canSetActionPlanSystemField,
+ update: canSetActionPlanSystemField,
+ },
+ },
+ ],
+ },
+ {
+ name: 'createdBy',
+ type: 'relationship',
+ relationTo: 'campaignUser',
+ label: 'Criado por',
+ index: true,
+ admin: {
+ readOnly: true,
+ },
+ access: {
+ create: canSetActionPlanSystemField,
+ update: canSetActionPlanSystemField,
+ },
+ },
+ ],
+}
diff --git a/src/collections/ElectoralNucleus.ts b/src/collections/ElectoralNucleus.ts
index 2d6fecc9..ee8d1f84 100644
--- a/src/collections/ElectoralNucleus.ts
+++ b/src/collections/ElectoralNucleus.ts
@@ -1,11 +1,7 @@
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
-import {
- isBahiaIdentityTerritory,
- isBahiaMunicipality,
- territoriesForCities,
-} from '@/lib/bahiaTerritories'
+import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import {
MAX_NUCLEUS_CITIES,
MAX_NUCLEUS_NEIGHBORHOODS,
@@ -22,6 +18,7 @@ import {
canSetDerivedNucleusField,
canUpdateElectoralNucleus,
} from '@/utilities/campaignAccess'
+import { createCampaignTerritoryValidationHook } from '@/utilities/campaignTerritoryValidation'
import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorOptions'
import { acquirePrimaryContactInvariantLocks } from '@/utilities/primaryContactInvariantLock'
import { relationshipId } from '@/utilities/relationship'
@@ -29,93 +26,12 @@ import { slugify } from '@/utilities/slug'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
-const normalizeTextArray = (value: unknown): string[] => {
- if (!Array.isArray(value)) return []
- const seen = new Set()
- const result: string[] = []
- for (const item of value) {
- const trimmed = typeof item === 'string' ? item.trim() : ''
- if (!trimmed || seen.has(trimmed)) continue
- seen.add(trimmed)
- result.push(trimmed)
- }
- return result
-}
-
-const resolveTerritoryArray = (
- data: Record | undefined,
- originalDoc: Record | undefined,
- field: 'regions' | 'cities' | 'neighborhoods',
- operation: 'create' | 'update' | 'delete',
-): string[] => {
- if (operation === 'update' && data && !(field in data)) {
- return normalizeTextArray(originalDoc?.[field])
- }
- return normalizeTextArray(data?.[field])
-}
-
-const validateNucleusTerritoryAndZones: CollectionBeforeValidateHook = ({
- data,
- operation,
- originalDoc,
-}) => {
+const validateNucleusTerritoryAndZones: CollectionBeforeValidateHook = (args) => {
+ const validateTerritory = createCampaignTerritoryValidationHook({ entityLabel: 'núcleo' })
+ const data = validateTerritory(args)
if (!data) return data
- const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
- const cities = resolveTerritoryArray(data, originalDoc, 'cities', operation)
- const neighborhoods = resolveTerritoryArray(data, originalDoc, 'neighborhoods', operation)
- let regions = resolveTerritoryArray(data, originalDoc, 'regions', operation)
- const locality = trimmedText(nextData?.locality)
- const territoryNotes = trimmedText(nextData?.territoryNotes)
-
- if (cities.length > MAX_NUCLEUS_CITIES) {
- throw new APIError(`Informe no máximo ${MAX_NUCLEUS_CITIES} municípios.`, 400)
- }
- if (regions.length > MAX_NUCLEUS_REGIONS) {
- throw new APIError(`Informe no máximo ${MAX_NUCLEUS_REGIONS} territórios.`, 400)
- }
- if (neighborhoods.length > MAX_NUCLEUS_NEIGHBORHOODS) {
- throw new APIError(`Informe no máximo ${MAX_NUCLEUS_NEIGHBORHOODS} bairros.`, 400)
- }
-
- for (const city of cities) {
- if (!isBahiaMunicipality(city)) {
- throw new APIError('Selecione um município válido da Bahia.', 400)
- }
- }
-
- if (cities.length > 0) {
- regions = territoriesForCities(cities)
- } else {
- for (const region of regions) {
- if (!isBahiaIdentityTerritory(region)) {
- throw new APIError('Selecione um território de identidade válido da Bahia.', 400)
- }
- }
- }
-
- if (neighborhoods.length > 0 && cities.length !== 1) {
- throw new APIError(
- cities.length === 0
- ? 'Informe o município antes do bairro.'
- : 'Bairros só podem ser informados quando há exatamente um município.',
- 400,
- )
- }
-
- if (regions.length === 0 && cities.length === 0 && !locality) {
- throw new APIError(
- 'Informe o território de identidade, município ou localidade do núcleo.',
- 400,
- )
- }
-
- data.regions = regions
- data.cities = cities
- data.neighborhoods = cities.length === 1 ? neighborhoods : []
- data.locality = locality || null
- data.territoryNotes = territoryNotes || null
-
+ const nextData = args.operation === 'update' ? { ...args.originalDoc, ...data } : data
const zones = Array.isArray(nextData.tseZones) ? nextData.tseZones : []
const zoneNumbers = zones.map((zone: { zoneNumber?: unknown }) => zone?.zoneNumber)
diff --git a/src/components/campaign/ActionPlanCard.tsx b/src/components/campaign/ActionPlanCard.tsx
new file mode 100644
index 00000000..c81f5ef5
--- /dev/null
+++ b/src/components/campaign/ActionPlanCard.tsx
@@ -0,0 +1,56 @@
+import Link from 'next/link'
+import { CalendarIcon, MapPinIcon, UserIcon } from 'lucide-react'
+
+import { ActionPlanStatusBadge } from '@/components/campaign/ActionPlanStatusBadge'
+import { Badge } from '@/components/ui/Badge'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
+import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
+import type { ActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
+
+const formatWhen = (plan: ActionPlanListViewModel): string => {
+ if (!plan.startAt) return 'Data a definir'
+ return plan.city ? `${formatBahiaDateTimeLabel(plan.startAt)} · ${plan.city}` : formatBahiaDateTimeLabel(plan.startAt)
+}
+
+export const ActionPlanCard = ({ plan }: { plan: ActionPlanListViewModel }) => (
+
+
+
+
{actionPlanKindLabels[plan.kind]}
+
+
+
+
+ {plan.title}
+
+
+
+
+
+
+ {formatWhen(plan)}
+
+ {plan.territoryLabel ? (
+
+
+ {plan.territoryLabel}
+
+ ) : null}
+
+
+
+ Resp: {plan.responsibleName ?? 'Não definido'}
+
+ {plan.taskProgress.total > 0 ? (
+
+ {plan.taskProgress.done}/{plan.taskProgress.total} tarefas
+
+ ) : null}
+
+
+
+)
diff --git a/src/components/campaign/ActionPlanFilters.tsx b/src/components/campaign/ActionPlanFilters.tsx
new file mode 100644
index 00000000..96ae9848
--- /dev/null
+++ b/src/components/campaign/ActionPlanFilters.tsx
@@ -0,0 +1,229 @@
+'use client'
+
+import { useRef, useState, useTransition } from 'react'
+import Link from 'next/link'
+import { usePathname, useRouter } from 'next/navigation'
+import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
+
+import { StrictCombobox } from '@/components/campaign/StrictCombobox'
+import { Button } from '@/components/ui/button'
+import { Field, FieldLabel } from '@/components/ui/field'
+import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+import { cn } from '@/lib/utils'
+import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan'
+import { territoryForCity } from '@/lib/bahiaTerritories'
+import {
+ actionPlanTabLabels,
+ actionPlanTabs,
+ buildActionPlanListSearchParams,
+ type ActionPlanListState,
+ type ActionPlanTab,
+} from '@/utilities/actionPlanUi'
+import {
+ municipalityComboboxOptions,
+ territoryComboboxOptions,
+} from '@/utilities/territoryComboboxOptions'
+
+type FilterValues = {
+ kind: string
+ status: string
+ region: string
+ city: string
+}
+
+const valuesFromState = (state: ActionPlanListState): FilterValues => ({
+ kind: state.kind ?? '',
+ status: state.status ?? '',
+ region: state.region ?? '',
+ city: state.city ?? '',
+})
+
+const buildTabHref = (state: ActionPlanListState, tab: ActionPlanTab): string => {
+ const params = buildActionPlanListSearchParams({
+ page: 1,
+ tab,
+ kind: state.kind,
+ status: tab === 'todos' ? state.status : undefined,
+ region: state.region,
+ city: state.city,
+ })
+ const query = params.toString()
+ return query ? `/campanha/planos?${query}` : '/campanha/planos'
+}
+
+const ActionPlanTabSwitch = ({ state }: { state: ActionPlanListState }) => (
+
+ {actionPlanTabs.map((tab) => {
+ const active = tab === state.tab
+ return (
+
+
+ {actionPlanTabLabels[tab]}
+
+
+ )
+ })}
+
+)
+
+export const ActionPlanFilters = ({ state }: { state: ActionPlanListState }) => {
+ const router = useRouter()
+ const pathname = usePathname()
+ const initialValues = valuesFromState(state)
+ const valuesRef = useRef(initialValues)
+ const [values, setValues] = useState(initialValues)
+ const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
+ const [isPending, startTransition] = useTransition()
+
+ const replaceValues = (nextValues: FilterValues) => {
+ valuesRef.current = nextValues
+ setValues(nextValues)
+ const params = buildActionPlanListSearchParams({
+ page: 1,
+ tab: state.tab,
+ kind: nextValues.kind as ActionPlanListState['kind'],
+ status: nextValues.status as ActionPlanListState['status'],
+ region: nextValues.region as ActionPlanListState['region'],
+ city: nextValues.city,
+ })
+ const query = params.toString()
+ startTransition(() => {
+ router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
+ })
+ }
+
+ const updateKind = (kind: string) => replaceValues({ ...valuesRef.current, kind })
+ const updateStatus = (status: string) => replaceValues({ ...valuesRef.current, status })
+
+ const updateTerritory = (region: string) => {
+ const current = valuesRef.current
+ const city = current.city && region && territoryForCity(current.city) !== region ? '' : current.city
+ replaceValues({ ...current, region, city })
+ }
+
+ const updateCity = (city: string) => {
+ const current = valuesRef.current
+ const region = territoryForCity(city) ?? current.region
+ replaceValues({ ...current, city, region })
+ }
+
+ const clearFilters = () =>
+ replaceValues({ kind: '', status: '', region: '', city: '' })
+
+ const hasFilters = Object.values(values).some(Boolean)
+
+ return (
+
+
+
+
+
+ {isPending ? 'Atualizando resultados…' : ''}
+
+
+
setMobileFiltersOpen((open) => !open)}
+ >
+
+ Filtros
+
+
+
+
+
+ Tipo de ação
+ updateKind(event.target.value)}
+ className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
+ >
+ Todos os tipos
+ {Object.entries(actionPlanKindLabels).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+ {state.tab === 'todos' ? (
+
+ Status
+ updateStatus(event.target.value)}
+ className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
+ >
+ Todos os status
+ {Object.entries(actionPlanStatusLabels).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+ ) : null}
+
+ Território de identidade
+
+
+
+ Município
+
+
+
+ {hasFilters ? (
+
+
+
+ Limpar filtros
+
+
+ ) : null}
+
+
+
+
+ )
+}
diff --git a/src/components/campaign/ActionPlanForm.tsx b/src/components/campaign/ActionPlanForm.tsx
new file mode 100644
index 00000000..f2a2cea4
--- /dev/null
+++ b/src/components/campaign/ActionPlanForm.tsx
@@ -0,0 +1,435 @@
+'use client'
+
+import { useActionState, useState } from 'react'
+import Link from 'next/link'
+
+import { ContactCombobox, type ContactComboboxOption } from '@/components/campaign/ContactCombobox'
+import { ActionPlanTaskFields } from '@/components/campaign/ActionPlanTaskFields'
+import { NucleusTerritoryFields } from '@/components/campaign/NucleusTerritoryFields'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Checkbox } from '@/components/ui/Checkbox'
+import {
+ Field,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLabel,
+ FieldLegend,
+ FieldSet,
+} 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 { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
+import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan'
+import type { ActionPlanLeadershipOption } from '@/utilities/actionPlanLeadershipOptions'
+import type { ActionPlanFormViewModel } from '@/utilities/actionPlanViewModels'
+import { formatIsoAsBahiaDateTimeInput } from '@/utilities/campaignTime'
+import type { NucleusCoordinatorOption } from '@/utilities/nucleusCoordinatorOptions'
+
+export type ActionPlanFormState = {
+ message?: string
+ fieldErrors?: Record
+ existingHref?: string
+ submittedTitle?: string
+}
+
+type ActionPlanFormAction = (
+ state: ActionPlanFormState,
+ formData: FormData,
+) => Promise
+
+export type { ActionPlanLeadershipOption }
+
+export type ActionPlanFormFieldsProps = {
+ coordinators?: NucleusCoordinatorOption[]
+ canManageCoordinators?: boolean
+ leadershipOptions?: ActionPlanLeadershipOption[]
+ plan?: ActionPlanFormViewModel
+ fieldErrors?: Record
+ submittedTitle?: string
+ searchContacts: (query: string) => Promise
+}
+
+const editableStatuses = ['rascunho', 'planejado'] as const
+
+export const ActionPlanFormFields = ({
+ coordinators = [],
+ canManageCoordinators = false,
+ leadershipOptions = [],
+ plan,
+ fieldErrors = {},
+ submittedTitle,
+ searchContacts,
+}: ActionPlanFormFieldsProps) => {
+ const errorFor = (name: string) => fieldErrors[name]?.[0]
+ const statusIsEditable =
+ !plan || editableStatuses.includes(plan.status as (typeof editableStatuses)[number])
+ const [status, setStatus] = useState<(typeof editableStatuses)[number]>(
+ plan && editableStatuses.includes(plan.status as (typeof editableStatuses)[number])
+ ? (plan.status as (typeof editableStatuses)[number])
+ : 'rascunho',
+ )
+ const [responsible, setResponsible] = useState(
+ plan?.responsible
+ ? { id: plan.responsible.id, name: plan.responsible.name, phone: plan.responsible.phone }
+ : null,
+ )
+
+ return (
+
+ {statusIsEditable ? (
+
+ ) : (
+
+ )}
+
+
+
+ Informações básicas
+
+
+
+
+ Título *
+
+ {errorFor('title') ? {errorFor('title')} : null}
+
+
+
+ Tipo de ação *
+
+ Selecione um tipo
+ {Object.entries(actionPlanKindLabels).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+ {errorFor('kind') ? {errorFor('kind')} : null}
+
+
+ Status *
+ {statusIsEditable ? (
+ <>
+ value && setStatus(value as typeof status)}
+ variant="outline"
+ className="flex w-full flex-wrap justify-start"
+ aria-label="Status do plano"
+ >
+ Planejado
+ Rascunho
+
+
+ Rascunhos não exigem data. Planos planejados exigem data e horário de início.
+
+ >
+ ) : (
+
+ Status atual: {actionPlanStatusLabels[plan?.status ?? ''] ?? plan?.status}. Use as
+ ações do detalhe para marcar como realizado ou cancelado.
+
+ )}
+
+
+
+ Descrição
+
+ {errorFor('description') ? (
+ {errorFor('description')}
+ ) : null}
+
+
+
+
+
+
+
+ Data e horário
+
+
+
+
+
+ Início {status !== 'rascunho' ? '*' : null}
+
+
+ {errorFor('startAt') ? (
+ {errorFor('startAt')}
+ ) : null}
+
+
+ Término
+
+ {errorFor('endAt') ? {errorFor('endAt')} : null}
+
+
+ Prazo de conclusão
+
+ {errorFor('deadline') ? (
+ {errorFor('deadline')}
+ ) : null}
+
+
+
+
+
+
+
+ Território
+
+
+
+
+
+
+ Localidade
+
+ {errorFor('locality') ? (
+ {errorFor('locality')}
+ ) : null}
+
+
+
+ Obrigatório: informe ao menos território de identidade, município ou localidade.
+
+
+ Observações do território
+
+ {errorFor('territoryNotes') ? (
+ {errorFor('territoryNotes')}
+ ) : null}
+
+
+
+
+
+
+
+ Pessoas
+
+
+
+
+ Responsável
+
+ {errorFor('responsible') ? (
+ {errorFor('responsible')}
+ ) : null}
+
+
+
+ Liderança vinculada
+
+ Nenhuma
+ {leadershipOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ Quando vinculada, a liderança engajada vê o plano e pode marcar tarefas e registrar
+ atualizações.
+
+ {errorFor('leadership') ? (
+ {errorFor('leadership')}
+ ) : null}
+
+
+ {canManageCoordinators ? (
+
+ Coordenadores responsáveis
+
+ Coordenadores selecionados podem editar este plano e gerenciar suas tarefas.
+
+
+ {coordinators.length ? (
+
+ {coordinators.map((coordinator) => (
+
+
+ id === coordinator.id)
+ : coordinator.isCurrent
+ }
+ />
+
+ {coordinator.name}
+ {coordinator.isCurrent ? ' (você)' : ''}
+
+
+
+ ))}
+
+ ) : (
+ Nenhum coordenador disponível.
+ )}
+ {errorFor('coordinators') ? (
+ {errorFor('coordinators')}
+ ) : null}
+
+ ) : null}
+
+
+
+
+
+
+ Tarefas
+
+
+
+
+
+
+ )
+}
+
+export const ActionPlanForm = ({
+ action,
+ coordinators,
+ canManageCoordinators = false,
+ leadershipOptions = [],
+ plan,
+ submitLabel,
+ searchContacts,
+}: ActionPlanFormFieldsProps & {
+ action: ActionPlanFormAction
+ submitLabel: string
+}) => {
+ const [state, formAction, pending] = useActionState(action, {})
+
+ return (
+
+ )
+}
diff --git a/src/components/campaign/ActionPlanLifecycleDialog.tsx b/src/components/campaign/ActionPlanLifecycleDialog.tsx
new file mode 100644
index 00000000..6271fbb4
--- /dev/null
+++ b/src/components/campaign/ActionPlanLifecycleDialog.tsx
@@ -0,0 +1,109 @@
+'use client'
+
+import { useActionState } from 'react'
+import { CheckCircle2Icon, XCircleIcon } from 'lucide-react'
+
+import {
+ cancelActionPlanFormAction,
+ markActionPlanRealizedFormAction,
+} from '@/app/(campaign)/campanha/(app)/planos/[slug]/lifecycleFormActions'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/AlertDialog'
+import { Button } from '@/components/ui/button'
+import { Spinner } from '@/components/ui/Spinner'
+
+type LifecycleDialogProps = {
+ planId: number
+}
+
+export const CancelActionPlanDialog = ({ planId }: LifecycleDialogProps) => {
+ const [state, action, pending] = useActionState(cancelActionPlanFormAction, {})
+
+ return (
+
+
+
+
+ Cancelar plano
+
+
+
+
+ Cancelar este plano?
+
+ O plano será marcado como cancelado. Os dados permanecem preservados no sistema.
+
+
+ {state.message ? (
+
+ Não foi possível cancelar
+ {state.message}
+
+ ) : null}
+
+
+
+ )
+}
+
+export const MarkActionPlanRealizedDialog = ({ planId }: LifecycleDialogProps) => {
+ const [state, action, pending] = useActionState(markActionPlanRealizedFormAction, {})
+
+ return (
+
+
+
+
+ Marcar como realizado
+
+
+
+
+ Marcar este plano como realizado?
+
+ Use esta ação após a execução da atividade. O status não poderá voltar a rascunho ou
+ planejado por aqui.
+
+
+ {state.message ? (
+
+ Não foi possível concluir
+ {state.message}
+
+ ) : null}
+
+
+
+ )
+}
diff --git a/src/components/campaign/ActionPlanList.tsx b/src/components/campaign/ActionPlanList.tsx
new file mode 100644
index 00000000..a0111b70
--- /dev/null
+++ b/src/components/campaign/ActionPlanList.tsx
@@ -0,0 +1,10 @@
+import { ActionPlanCard } from '@/components/campaign/ActionPlanCard'
+import type { ActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
+
+export const ActionPlanList = ({ plans }: { plans: ActionPlanListViewModel[] }) => (
+
+ {plans.map((plan) => (
+
+ ))}
+
+)
diff --git a/src/components/campaign/ActionPlanPagination.tsx b/src/components/campaign/ActionPlanPagination.tsx
new file mode 100644
index 00000000..713d825d
--- /dev/null
+++ b/src/components/campaign/ActionPlanPagination.tsx
@@ -0,0 +1,61 @@
+import { getNucleusPaginationPages } from '@/components/campaign/NucleusPagination'
+import {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from '@/components/ui/Pagination'
+import { buildActionPlanListHref, type ActionPlanListState } from '@/utilities/actionPlanUi'
+
+export const ActionPlanPagination = ({
+ state,
+ totalPages,
+}: {
+ state: ActionPlanListState
+ totalPages: number
+}) => {
+ if (totalPages <= 1) return null
+
+ const visiblePages = getNucleusPaginationPages(state.page, totalPages)
+
+ return (
+
+
+
+
+
+ {visiblePages.map((page, index) => {
+ const previousPage = visiblePages[index - 1]
+ const hasGap = previousPage !== undefined && page - previousPage > 1
+
+ return (
+
+ {hasGap ? : null}
+
+ {page}
+
+
+ )
+ })}
+
+ = totalPages}
+ className={state.page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
+ />
+
+
+
+ )
+}
diff --git a/src/components/campaign/ActionPlanStatusBadge.tsx b/src/components/campaign/ActionPlanStatusBadge.tsx
new file mode 100644
index 00000000..d7925c23
--- /dev/null
+++ b/src/components/campaign/ActionPlanStatusBadge.tsx
@@ -0,0 +1,19 @@
+import { Badge, type badgeVariants } from '@/components/ui/Badge'
+import { actionPlanStatusLabels } from '@/lib/schemas/actionPlan'
+import type { ActionPlanStatus } from '@/utilities/actionPlanUi'
+
+type BadgeVariant = NonNullable[0]>['variant']
+
+const statusVariant: Record = {
+ rascunho: 'outline',
+ planejado: 'secondary',
+ confirmado: 'default',
+ realizado: 'estimate-confirmed',
+ cancelado: 'destructive',
+}
+
+export const ActionPlanStatusBadge = ({ status }: { status: ActionPlanStatus }) => (
+
+ {actionPlanStatusLabels[status]}
+
+)
diff --git a/src/components/campaign/ActionPlanTabNav.tsx b/src/components/campaign/ActionPlanTabNav.tsx
new file mode 100644
index 00000000..793b6954
--- /dev/null
+++ b/src/components/campaign/ActionPlanTabNav.tsx
@@ -0,0 +1,56 @@
+import Link from 'next/link'
+
+import { cn } from '@/lib/utils'
+import {
+ actionPlanDetailTabs,
+ buildActionPlanDetailTabHref,
+ type ActionPlanDetailSearchParams,
+ type ActionPlanDetailTab,
+} from '@/utilities/actionPlanDetailTabUi'
+
+const tabLabels: Record = {
+ overview: 'Visão geral',
+ tasks: 'Tarefas',
+ updates: 'Atualizações',
+}
+
+export const ActionPlanTabNav = ({
+ activeTab,
+ planSlug,
+ searchParams,
+}: {
+ activeTab: ActionPlanDetailTab
+ planSlug: string
+ searchParams: ActionPlanDetailSearchParams
+}) => (
+
+
+ {actionPlanDetailTabs.map((tab) => {
+ const isActive = tab === activeTab
+ return (
+
+
+ {tabLabels[tab]}
+
+
+ )
+ })}
+
+
+)
diff --git a/src/components/campaign/ActionPlanTaskChecklist.tsx b/src/components/campaign/ActionPlanTaskChecklist.tsx
new file mode 100644
index 00000000..c5efcd45
--- /dev/null
+++ b/src/components/campaign/ActionPlanTaskChecklist.tsx
@@ -0,0 +1,68 @@
+'use client'
+
+import { startTransition, useOptimistic } from 'react'
+import { toast } from 'sonner'
+
+import { toggleActionPlanTaskAction } from '@/app/(campaign)/campanha/(app)/planos/[slug]/taskActions'
+import { Checkbox } from '@/components/ui/Checkbox'
+import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
+import type { ActionPlanTaskViewModel } from '@/utilities/actionPlanViewModels'
+
+type ActionPlanTaskChecklistProps = {
+ planId: number
+ tasks: ActionPlanTaskViewModel[]
+}
+
+export const ActionPlanTaskChecklist = ({ planId, tasks }: ActionPlanTaskChecklistProps) => {
+ const [optimisticTasks, setOptimisticDone] = useOptimistic(
+ tasks,
+ (currentTasks, { taskId, done }: { taskId: string; done: boolean }) =>
+ currentTasks.map((task) => (task.id === taskId ? { ...task, done } : task)),
+ )
+
+ const toggle = (taskId: string | null, done: boolean) => {
+ if (!taskId) return
+
+ startTransition(async () => {
+ setOptimisticDone({ taskId, done })
+ const result = await toggleActionPlanTaskAction(planId, taskId, done)
+ if (!result.ok) toast.error(result.message)
+ })
+ }
+
+ if (!optimisticTasks.length) {
+ return Nenhuma tarefa cadastrada.
+ }
+
+ return (
+
+ {optimisticTasks.map((task) => (
+
+ toggle(task.id, checked === true)}
+ aria-label={`Marcar tarefa "${task.title}" como ${task.done ? 'pendente' : 'concluída'}`}
+ className="mt-0.5"
+ />
+
+
+ {task.title}
+
+
+ {[
+ task.responsibleName ? `Resp: ${task.responsibleName}` : null,
+ task.due ? `Prazo: ${formatBahiaDateTimeLabel(task.due)}` : null,
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/components/campaign/ActionPlanTaskFields.tsx b/src/components/campaign/ActionPlanTaskFields.tsx
new file mode 100644
index 00000000..2bcc39c9
--- /dev/null
+++ b/src/components/campaign/ActionPlanTaskFields.tsx
@@ -0,0 +1,130 @@
+'use client'
+
+import { useId, useState } from 'react'
+import { PlusIcon, Trash2Icon } from 'lucide-react'
+
+import { ContactCombobox, type ContactComboboxOption } from '@/components/campaign/ContactCombobox'
+import { Button } from '@/components/ui/button'
+import { Field, FieldError, FieldLabel } from '@/components/ui/field'
+import { Input } from '@/components/ui/input'
+import { formatIsoAsBahiaDateTimeInput } from '@/utilities/campaignTime'
+
+export type ActionPlanTaskFieldValue = {
+ key: string
+ title: string
+ responsible: ContactComboboxOption | null
+ due: string
+ done: boolean
+}
+
+type ActionPlanTaskFieldsProps = {
+ initialTasks: Array<{
+ id: string | null
+ title: string
+ responsible: ContactComboboxOption | null
+ due: string | null
+ done: boolean
+ }>
+ searchContacts: (query: string) => Promise
+ error?: string
+}
+
+let taskKeySequence = 0
+const nextTaskKey = (): string => {
+ taskKeySequence += 1
+ return `new-${taskKeySequence}`
+}
+
+const serializeTasks = (tasks: ActionPlanTaskFieldValue[]) =>
+ JSON.stringify(
+ tasks
+ .filter((task) => task.title.trim().length > 0)
+ .map((task) => ({
+ title: task.title.trim(),
+ ...(task.responsible ? { responsible: task.responsible.id } : {}),
+ ...(task.due ? { due: task.due } : {}),
+ done: task.done,
+ })),
+ )
+
+export const ActionPlanTaskFields = ({
+ initialTasks,
+ searchContacts,
+ error,
+}: ActionPlanTaskFieldsProps) => {
+ const fieldId = useId()
+ const [tasks, setTasks] = useState(() =>
+ initialTasks.map((task) => ({
+ key: task.id ?? nextTaskKey(),
+ title: task.title,
+ responsible: task.responsible,
+ due: task.due ? formatIsoAsBahiaDateTimeInput(task.due) : '',
+ done: task.done,
+ })),
+ )
+
+ const updateTask = (key: string, patch: Partial) => {
+ setTasks((current) => current.map((task) => (task.key === key ? { ...task, ...patch } : task)))
+ }
+
+ const addTask = () => {
+ setTasks((current) => [
+ ...current,
+ { key: nextTaskKey(), title: '', responsible: null, due: '', done: false },
+ ])
+ }
+
+ const removeTask = (key: string) => {
+ setTasks((current) => current.filter((task) => task.key !== key))
+ }
+
+ return (
+
+ Tarefas
+
+
+ {tasks.map((task, index) => (
+
+ ))}
+
+
+
+ Adicionar tarefa
+
+ {error ? {error} : null}
+
+ )
+}
diff --git a/src/components/campaign/ActionPlanUpdateFeed.tsx b/src/components/campaign/ActionPlanUpdateFeed.tsx
new file mode 100644
index 00000000..d9e95d71
--- /dev/null
+++ b/src/components/campaign/ActionPlanUpdateFeed.tsx
@@ -0,0 +1,50 @@
+import { MessageSquareTextIcon } from 'lucide-react'
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/Empty'
+import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
+import type { ActionPlanUpdateViewModel } from '@/utilities/actionPlanViewModels'
+
+const UpdateCard = ({ update }: { update: ActionPlanUpdateViewModel }) => (
+
+
+
+ {update.authorName ?? 'Autor removido'}
+
+ {update.createdAt ? formatBahiaDateTimeLabel(update.createdAt) : ''}
+
+
+
+ {update.body}
+
+
+
+)
+
+export const ActionPlanUpdateFeed = ({ updates }: { updates: ActionPlanUpdateViewModel[] }) => {
+ if (!updates.length) {
+ return (
+
+
+
+
+
+ Nenhuma atualização registrada
+
+ Envie o primeiro reporte para iniciar o histórico cronológico deste plano.
+
+
+
+ )
+ }
+
+ const sortedUpdates = [...updates].reverse()
+
+ return (
+
+ {sortedUpdates.map((update, index) => (
+
+ ))}
+
+ )
+}
diff --git a/src/components/campaign/ActionPlanUpdateForm.tsx b/src/components/campaign/ActionPlanUpdateForm.tsx
new file mode 100644
index 00000000..9bb5ad7b
--- /dev/null
+++ b/src/components/campaign/ActionPlanUpdateForm.tsx
@@ -0,0 +1,64 @@
+'use client'
+
+import { useActionState, useEffect, useRef } from 'react'
+import { useRouter } from 'next/navigation'
+import { toast } from 'sonner'
+
+import { createActionPlanUpdateFormAction } from '@/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Field, FieldError, FieldLabel } from '@/components/ui/field'
+import { Spinner } from '@/components/ui/Spinner'
+import { Textarea } from '@/components/ui/textarea'
+
+export const ActionPlanUpdateForm = ({ planId }: { planId: number }) => {
+ const router = useRouter()
+ const formRef = useRef(null)
+ const [state, formAction, pending] = useActionState(createActionPlanUpdateFormAction, {})
+
+ useEffect(() => {
+ if (state.status !== 'success') return
+ toast.success(state.message)
+ formRef.current?.reset()
+ router.refresh()
+ }, [router, state.message, state.status])
+
+ const bodyError = state.fieldErrors?.body?.[0]
+
+ return (
+
+
+ Nova atualização
+
+
+
+
+
+ )
+}
diff --git a/src/components/campaign/CampaignBottomNav.tsx b/src/components/campaign/CampaignBottomNav.tsx
index 149fe19e..6b43677e 100644
--- a/src/components/campaign/CampaignBottomNav.tsx
+++ b/src/components/campaign/CampaignBottomNav.tsx
@@ -17,10 +17,8 @@ export const CampaignBottomNav = ({ role }: { role: CampaignUser['role'] }) => {
className="fixed inset-x-0 bottom-0 border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden"
>
= 3 ? 'grid-cols-3' : 'grid-cols-2',
- )}
+ className="m-0 grid list-none p-0"
+ style={{ gridTemplateColumns: `repeat(${navItems.length}, minmax(0, 1fr))` }}
>
{navItems.map((item) => {
const active = isCampaignNavActive(pathname, item.href)
diff --git a/src/components/campaign/CampaignDashboard.tsx b/src/components/campaign/CampaignDashboard.tsx
index 34821436..f178796a 100644
--- a/src/components/campaign/CampaignDashboard.tsx
+++ b/src/components/campaign/CampaignDashboard.tsx
@@ -30,7 +30,10 @@ import {
EmptyTitle,
} from '@/components/ui/Empty'
import { Progress } from '@/components/ui/Progress'
+import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import { leadershipSupportStatuses } from '@/lib/schemas/leadership'
+import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
+import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import type {
DashboardQueueItem,
GeneralDashboardViewModel,
@@ -118,6 +121,45 @@ const QueueList = ({
)
+const UpcomingActionPlansCard = ({ plans }: { plans: ActionPlanUpcomingPreviewRecord[] }) => (
+
+
+ Próximos eventos
+
+
+ Ver todos
+
+
+
+
+
+ {plans.length ? (
+
+ ) : (
+ Nenhum evento agendado
+ )}
+
+
+)
+
const GeneralDashboard = ({ view }: { view: GeneralDashboardViewModel }) => (
@@ -202,6 +244,8 @@ const GeneralDashboard = ({ view }: { view: GeneralDashboardViewModel }) => (
/>
+
+
)
@@ -218,6 +262,9 @@ const CoordinatorDashboard = ({
Sua região
O que precisa de atenção nos seus núcleos?
+
+
+
{view.cards.length ? (
{view.cards.map((card) => (
@@ -277,6 +324,9 @@ const LeadershipDashboard = ({
Envie seu reporte e acompanhe a estimativa confirmada.
+
+
+
{view.cards.length ? (
{view.cards.map((card) => (
diff --git a/src/components/campaign/ContactCombobox.tsx b/src/components/campaign/ContactCombobox.tsx
new file mode 100644
index 00000000..f1cc0406
--- /dev/null
+++ b/src/components/campaign/ContactCombobox.tsx
@@ -0,0 +1,163 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { ChevronsUpDownIcon } from 'lucide-react'
+
+import { Button } from '@/components/ui/button'
+import {
+ Command,
+ CommandDialog,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/Command'
+import { Spinner } from '@/components/ui/Spinner'
+
+export type ContactComboboxOption = {
+ id: number
+ name: string
+ phone: string
+}
+
+type ContactComboboxProps = {
+ name?: string
+ label: string
+ current: ContactComboboxOption | null
+ search: (query: string) => Promise
+ emptyOptionLabel?: string
+ onChange?: (contact: ContactComboboxOption | null) => void
+}
+
+const contactLabel = (contact: ContactComboboxOption | null, emptyLabel: string): string =>
+ contact ? `${contact.name} · ${contact.phone}` : emptyLabel
+
+export const ContactCombobox = ({
+ name,
+ label,
+ current,
+ search,
+ emptyOptionLabel = 'Nenhum responsável',
+ onChange,
+}: ContactComboboxProps) => {
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState('')
+ const [selected, setSelected] = useState(current)
+ const [options, setOptions] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [failed, setFailed] = useState(false)
+ const requestId = useRef(0)
+
+ useEffect(() => {
+ if (!open) return
+
+ const currentRequestId = ++requestId.current
+ const timeout = window.setTimeout(() => {
+ setLoading(true)
+ setFailed(false)
+ void search(query)
+ .then((nextOptions) => {
+ if (requestId.current !== currentRequestId) return
+ setOptions(nextOptions)
+ })
+ .catch(() => {
+ if (requestId.current !== currentRequestId) return
+ setFailed(true)
+ })
+ .finally(() => {
+ if (requestId.current === currentRequestId) setLoading(false)
+ })
+ }, 250)
+
+ return () => window.clearTimeout(timeout)
+ }, [open, query, search])
+
+ const choose = (contact: ContactComboboxOption | null) => {
+ setSelected(contact)
+ setOpen(false)
+ setQuery('')
+ onChange?.(contact)
+ }
+
+ const visibleOptions = options.filter((option) => option.id !== selected?.id)
+
+ return (
+ <>
+ {name ? : null}
+ setOpen(true)}
+ >
+ {contactLabel(selected, emptyOptionLabel)}
+
+
+
+
+
+
+ {loading ? (
+
+
+ Buscando…
+
+ ) : failed ? (
+
+ Não foi possível buscar contatos.
+
+ ) : (
+ <>
+
+ choose(null)}>
+ {emptyOptionLabel}
+
+
+ {selected ? (
+
+ choose(selected)}
+ >
+ {contactLabel(selected, emptyOptionLabel)}
+
+
+ ) : null}
+ {visibleOptions.length ? (
+
+ {visibleOptions.map((contact) => (
+ choose(contact)}
+ >
+ {contactLabel(contact, emptyOptionLabel)}
+
+ ))}
+
+ ) : (
+
+ Nenhum contato encontrado.
+
+ )}
+ >
+ )}
+
+
+
+ >
+ )
+}
diff --git a/src/components/campaign/NucleusListOverview.tsx b/src/components/campaign/NucleusListOverview.tsx
index c80dcc73..ba236b0d 100644
--- a/src/components/campaign/NucleusListOverview.tsx
+++ b/src/components/campaign/NucleusListOverview.tsx
@@ -3,6 +3,8 @@ import Link from 'next/link'
import { Badge } from '@/components/ui/Badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/Progress'
+import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
+import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import type { NucleusListOverviewViewModel } from '@/utilities/nucleusListOverviewViewModels'
import { nucleusUpdateKindLabels } from '@/utilities/nucleusUpdateUi'
@@ -122,6 +124,40 @@ export const NucleusListOverview = ({
- {/* Extension points for future roadmap domains (Eventos / Demandas): plug new cards here when those collections exist. */}
+
+
+ Próximos eventos
+
+
+ {view.upcomingActionPlans.length ? (
+
+ ) : (
+ Nenhum evento agendado
+ )}
+
+
+
+ {/* Extension points for future roadmap domains (Demandas): plug new cards here when those collections exist. */}
)
diff --git a/src/components/campaign/NucleusTerritoryFields.tsx b/src/components/campaign/NucleusTerritoryFields.tsx
new file mode 100644
index 00000000..81a044e7
--- /dev/null
+++ b/src/components/campaign/NucleusTerritoryFields.tsx
@@ -0,0 +1,319 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import { XIcon } from 'lucide-react'
+
+import { StrictCombobox } from '@/components/campaign/StrictCombobox'
+import { TerritorySuggestionChips } from '@/components/campaign/TerritorySuggestionChips'
+import { Badge } from '@/components/ui/Badge'
+import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
+import { InputGroup, InputGroupInput } from '@/components/ui/input-group'
+import { territoriesForCities } from '@/lib/bahiaTerritories'
+import {
+ dedupeTrimmedStrings,
+ MAX_NUCLEUS_CITIES,
+ MAX_NUCLEUS_NEIGHBORHOODS,
+ MAX_NUCLEUS_REGIONS,
+} from '@/lib/schemas/nucleus'
+import { buildTerritorySuggestions } from '@/lib/territorySuggestions'
+import {
+ allMunicipalityComboboxOptions,
+ territoryComboboxOptions,
+} from '@/utilities/territoryComboboxOptions'
+
+type TerritoryValues = {
+ regions?: string[] | null
+ cities?: string[] | null
+ neighborhoods?: string[] | null
+}
+
+const ChipList = ({
+ values,
+ onRemove,
+ removeLabel,
+}: {
+ values: string[]
+ onRemove: (value: string) => void
+ removeLabel: (value: string) => string
+}) => (
+
+ {values.map((value) => (
+
+ onRemove(value)} aria-label={removeLabel(value)}>
+ {value}
+
+
+
+ ))}
+
+)
+
+/**
+ * Territory-only fields (regions/cities/neighborhoods) shared by any campaign
+ * form that needs Bahia territory selection without TSE zones — see
+ * `NucleusTerritoryAndZonesFields` for the nucleus variant that also manages
+ * TSE zones.
+ */
+export const NucleusTerritoryFields = ({
+ values,
+ fieldErrors = {},
+}: {
+ values?: TerritoryValues
+ fieldErrors?: Record
+}) => {
+ const [regions, setRegions] = useState(() => dedupeTrimmedStrings(values?.regions ?? []))
+ const [cities, setCities] = useState(() => dedupeTrimmedStrings(values?.cities ?? []))
+ const [neighborhoods, setNeighborhoods] = useState(() =>
+ dedupeTrimmedStrings(values?.neighborhoods ?? []),
+ )
+ const [cityDraft, setCityDraft] = useState('')
+ const [regionDraft, setRegionDraft] = useState('')
+ const [neighborhoodDraft, setNeighborhoodDraft] = useState('')
+ const [regionError, setRegionError] = useState()
+ const [cityError, setCityError] = useState()
+ const [neighborhoodError, setNeighborhoodError] = useState()
+
+ const errorFor = (name: string) => fieldErrors[name]?.[0]
+ const derivedRegions = useMemo(() => territoriesForCities(cities), [cities])
+ const displayRegions = cities.length > 0 ? derivedRegions : regions
+ const regionsAreDerived = cities.length > 0
+ const neighborhoodsEnabled = cities.length === 1
+
+ const { citySuggestions } = useMemo(
+ () =>
+ buildTerritorySuggestions({
+ cities,
+ regions: displayRegions,
+ tseZones: [],
+ }),
+ [cities, displayRegions],
+ )
+
+ const visibleRegionError = regionError ?? errorFor('regions')
+ const visibleCityError = cityError ?? errorFor('cities')
+ const visibleNeighborhoodError = neighborhoodError ?? errorFor('neighborhoods')
+
+ const availableCityOptions = useMemo(() => {
+ const selected = new Set(cities)
+ return allMunicipalityComboboxOptions.filter((option) => !selected.has(option.value))
+ }, [cities])
+ const availableRegionOptions = useMemo(() => {
+ const selected = new Set(regions)
+ return territoryComboboxOptions.filter((option) => !selected.has(option.value))
+ }, [regions])
+
+ const addCity = (nextCity: string) => {
+ if (!nextCity) return
+ if (cities.length >= MAX_NUCLEUS_CITIES) {
+ setCityError(`Informe no máximo ${MAX_NUCLEUS_CITIES} municípios.`)
+ setCityDraft('')
+ return
+ }
+ setCities((current) => {
+ const next = dedupeTrimmedStrings([...current, nextCity])
+ if (next.length !== 1) setNeighborhoods([])
+ return next
+ })
+ setCityDraft('')
+ setCityError(undefined)
+ setRegionError(undefined)
+ }
+
+ const removeCity = (city: string) => {
+ setCities((current) => {
+ const next = current.filter((item) => item !== city)
+ if (next.length !== 1) setNeighborhoods([])
+ return next
+ })
+ setCityError(undefined)
+ }
+
+ const addRegion = (nextRegion: string) => {
+ if (!nextRegion || regionsAreDerived) return
+ if (regions.length >= MAX_NUCLEUS_REGIONS) {
+ setRegionError(`Informe no máximo ${MAX_NUCLEUS_REGIONS} territórios.`)
+ setRegionDraft('')
+ return
+ }
+ setRegions((current) => dedupeTrimmedStrings([...current, nextRegion]))
+ setRegionDraft('')
+ setRegionError(undefined)
+ }
+
+ const removeRegion = (region: string) => {
+ if (regionsAreDerived) return
+ setRegions((current) => current.filter((item) => item !== region))
+ setRegionError(undefined)
+ }
+
+ const commitNeighborhood = (value: string) => {
+ if (!neighborhoodsEnabled) {
+ setNeighborhoodDraft('')
+ return
+ }
+ const trimmed = value.trim()
+ if (!trimmed) {
+ setNeighborhoodDraft('')
+ return
+ }
+ if (neighborhoods.length >= MAX_NUCLEUS_NEIGHBORHOODS) {
+ setNeighborhoodError(`Informe no máximo ${MAX_NUCLEUS_NEIGHBORHOODS} bairros.`)
+ setNeighborhoodDraft('')
+ return
+ }
+ setNeighborhoods((current) => dedupeTrimmedStrings([...current, trimmed]))
+ setNeighborhoodDraft('')
+ setNeighborhoodError(undefined)
+ }
+
+ return (
+
+ {cities.map((city) => (
+
+ ))}
+ {!regionsAreDerived
+ ? regions.map((region) => (
+
+ ))
+ : null}
+ {neighborhoodsEnabled
+ ? neighborhoods.map((neighborhood) => (
+
+ ))
+ : null}
+
+
+ Territórios de identidade
+ {displayRegions.length ? (
+ `Remover território ${region}`}
+ />
+ ) : null}
+ {regionsAreDerived ? (
+
+ Derivados automaticamente dos municípios selecionados. Remova municípios para editar
+ territórios manualmente.
+
+ ) : (
+ {
+ if (nextRegion) addRegion(nextRegion)
+ else setRegionDraft('')
+ }}
+ onInvalid={() => {
+ setRegionError('Selecione um território de identidade válido da Bahia.')
+ }}
+ error={visibleRegionError}
+ />
+ )}
+ {visibleRegionError ? (
+ {visibleRegionError}
+ ) : null}
+
+
+
+ Municípios
+ addCity(suggestion.city)}
+ />
+ {cities.length ? (
+ `Remover município ${city}`}
+ />
+ ) : null}
+ {
+ if (nextCity) addCity(nextCity)
+ else setCityDraft('')
+ }}
+ onInvalid={() => {
+ setCityError('Selecione um município válido da Bahia.')
+ }}
+ error={visibleCityError}
+ />
+
+ Selecione um ou mais municípios. Territórios são derivados automaticamente; bairros só
+ ficam disponíveis com exatamente um município.
+
+ {visibleCityError ? (
+ {visibleCityError}
+ ) : null}
+
+
+
+ Bairros
+ {neighborhoodsEnabled && neighborhoods.length ? (
+
+ setNeighborhoods((current) => current.filter((item) => item !== neighborhood))
+ }
+ removeLabel={(neighborhood) => `Remover bairro ${neighborhood}`}
+ />
+ ) : null}
+
+ {
+ setNeighborhoodDraft(event.target.value)
+ setNeighborhoodError(undefined)
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ commitNeighborhood(neighborhoodDraft)
+ } else if (
+ event.key === 'Backspace' &&
+ !neighborhoodDraft &&
+ neighborhoods.length > 0
+ ) {
+ setNeighborhoods((current) => current.slice(0, -1))
+ }
+ }}
+ onBlur={() => commitNeighborhood(neighborhoodDraft)}
+ />
+
+
+ Use Enter para adicionar. Disponível somente quando há exatamente um município.
+
+ {visibleNeighborhoodError ? (
+ {visibleNeighborhoodError}
+ ) : null}
+
+
+ )
+}
diff --git a/src/components/campaign/nav.ts b/src/components/campaign/nav.ts
index 1770ab4e..7f435ee3 100644
--- a/src/components/campaign/nav.ts
+++ b/src/components/campaign/nav.ts
@@ -1,4 +1,4 @@
-import { HomeIcon, Layers3Icon, UsersIcon, type LucideIcon } from 'lucide-react'
+import { CalendarDaysIcon, HomeIcon, Layers3Icon, UsersIcon, type LucideIcon } from 'lucide-react'
import type { CampaignUser } from '@/payload-types'
import { canAccessSupporterArea } from '@/utilities/supporterUi'
@@ -20,6 +20,11 @@ export const campaignNav: CampaignNavItem[] = [
href: '/campanha/nucleos',
icon: Layers3Icon,
},
+ {
+ title: 'Planos',
+ href: '/campanha/planos',
+ icon: CalendarDaysIcon,
+ },
{
title: 'Apoiadores',
href: '/campanha/apoiadores',
diff --git a/src/lib/schemas/actionPlan.ts b/src/lib/schemas/actionPlan.ts
new file mode 100644
index 00000000..ec0f0c07
--- /dev/null
+++ b/src/lib/schemas/actionPlan.ts
@@ -0,0 +1,281 @@
+import { z } from 'zod'
+
+import {
+ isBahiaIdentityTerritory,
+ isBahiaMunicipality,
+ territoriesForCities,
+} from '@/lib/bahiaTerritories'
+import {
+ dedupeTrimmedStrings,
+ MAX_NUCLEUS_CITIES,
+ MAX_NUCLEUS_NEIGHBORHOODS,
+ MAX_NUCLEUS_REGIONS,
+} from '@/lib/schemas/nucleus'
+import {
+ positiveRelationshipId,
+ trimmedNullableText,
+ trimmedOptionalText,
+} from '@/lib/schemas/primitives'
+
+export const actionPlanKinds = [
+ 'caminhada',
+ 'comicio',
+ 'carreata',
+ 'panfletagem',
+ 'porta_a_porta',
+ 'reuniao_apoio',
+ 'lancamento',
+ 'convencao',
+ 'ato',
+ 'entrevista',
+ 'producao_conteudo',
+ 'digital',
+ 'outro',
+] as const
+
+export const actionPlanStatuses = [
+ 'rascunho',
+ 'planejado',
+ 'confirmado',
+ 'realizado',
+ 'cancelado',
+] as const
+
+export const actionPlanKindLabels: Record<(typeof actionPlanKinds)[number], string> = {
+ caminhada: 'Caminhada',
+ comicio: 'Comício',
+ carreata: 'Carreata',
+ panfletagem: 'Panfletagem',
+ porta_a_porta: 'Porta a porta',
+ reuniao_apoio: 'Reunião de apoio',
+ lancamento: 'Lançamento',
+ convencao: 'Convenção',
+ ato: 'Ato',
+ entrevista: 'Entrevista',
+ producao_conteudo: 'Produção de conteúdo',
+ digital: 'Digital',
+ outro: 'Outro',
+}
+
+export const actionPlanStatusLabels: Record<(typeof actionPlanStatuses)[number], string> = {
+ rascunho: 'Rascunho',
+ planejado: 'Planejado',
+ confirmado: 'Confirmado',
+ realizado: 'Realizado',
+ cancelado: 'Cancelado',
+}
+
+const regionsArraySchema = z
+ .array(z.string())
+ .max(MAX_NUCLEUS_REGIONS)
+ .transform(dedupeTrimmedStrings)
+ .superRefine((regions, context) => {
+ for (const [index, region] of regions.entries()) {
+ if (!isBahiaIdentityTerritory(region)) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Selecione um território de identidade válido da Bahia.',
+ path: [index],
+ })
+ }
+ }
+ })
+
+const citiesArraySchema = z
+ .array(z.string())
+ .max(MAX_NUCLEUS_CITIES)
+ .transform(dedupeTrimmedStrings)
+ .superRefine((cities, context) => {
+ for (const [index, city] of cities.entries()) {
+ if (city.length > 120 || !isBahiaMunicipality(city)) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Selecione um município válido da Bahia.',
+ path: [index],
+ })
+ }
+ }
+ })
+
+const neighborhoodsArraySchema = z
+ .array(z.string().max(160))
+ .max(MAX_NUCLEUS_NEIGHBORHOODS)
+ .transform(dedupeTrimmedStrings)
+
+const taskSchema = z.object({
+ title: z.string().trim().min(1).max(200),
+ responsible: positiveRelationshipId.optional(),
+ due: z.string().datetime().optional().nullable(),
+ done: z.boolean().optional(),
+})
+
+const actionPlanFieldsSchema = z.object({
+ title: z.string().trim().min(2).max(160),
+ kind: z.enum(actionPlanKinds),
+ status: z.enum(actionPlanStatuses).optional(),
+ description: trimmedOptionalText(4000),
+ startAt: z.string().datetime().optional().nullable(),
+ endAt: z.string().datetime().optional().nullable(),
+ deadline: z.string().datetime().optional().nullable(),
+ regions: regionsArraySchema.optional(),
+ cities: citiesArraySchema.optional(),
+ neighborhoods: neighborhoodsArraySchema.optional(),
+ locality: trimmedOptionalText(160),
+ territoryNotes: trimmedOptionalText(2000),
+ coordinators: z.array(positiveRelationshipId).optional(),
+ responsible: positiveRelationshipId.optional(),
+ leadership: positiveRelationshipId.optional(),
+ tasks: z.array(taskSchema).optional(),
+})
+
+type TerritoryValidationInput = {
+ regions?: string[] | null
+ cities?: string[] | null
+ neighborhoods?: string[] | null
+ locality?: string | null
+}
+
+const validateTerritory = (
+ data: TerritoryValidationInput,
+ context: z.RefinementCtx,
+ mode: 'create' | 'patch',
+) => {
+ const regions = data.regions ?? []
+ const cities = data.cities ?? []
+ const neighborhoods = data.neighborhoods ?? []
+ const locality = data.locality ?? undefined
+
+ const geographyWasFullyProvided =
+ data.regions !== undefined && data.cities !== undefined && data.locality !== undefined
+ if (
+ regions.length === 0 &&
+ cities.length === 0 &&
+ !locality &&
+ (mode === 'create' || geographyWasFullyProvided)
+ ) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Informe o território de identidade, município ou localidade do plano.',
+ path: ['cities'],
+ })
+ }
+
+ if (neighborhoods.length > 0 && cities.length !== 1) {
+ context.addIssue({
+ code: 'custom',
+ message:
+ cities.length === 0
+ ? 'Informe o município antes do bairro.'
+ : 'Bairros só podem ser informados quando há exatamente um município.',
+ path: ['neighborhoods'],
+ })
+ }
+}
+
+const validateSchedule = (
+ data: {
+ status?: (typeof actionPlanStatuses)[number] | null
+ startAt?: string | null
+ endAt?: string | null
+ },
+ context: z.RefinementCtx,
+ mode: 'create' | 'patch',
+) => {
+ const status = data.status ?? (mode === 'create' ? 'rascunho' : undefined)
+ if (status && status !== 'rascunho' && !data.startAt) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Informe a data e horário de início ao planejar ou confirmar o plano.',
+ path: ['startAt'],
+ })
+ }
+
+ if (data.startAt && data.endAt) {
+ const start = new Date(data.startAt)
+ const end = new Date(data.endAt)
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || !(start < end)) {
+ context.addIssue({
+ code: 'custom',
+ message: 'O horário de término deve ser posterior ao de início.',
+ path: ['endAt'],
+ })
+ }
+ }
+}
+
+export const actionPlanCreateSchema = actionPlanFieldsSchema
+ .extend({
+ status: z.enum(['rascunho', 'planejado']).default('rascunho'),
+ })
+ .superRefine((data, context) => {
+ validateTerritory(data, context, 'create')
+ validateSchedule(data, context, 'create')
+ })
+ .transform((data) => {
+ const cities = data.cities ?? []
+ const regions =
+ cities.length > 0
+ ? territoriesForCities(cities)
+ : (data.regions ?? []).filter(isBahiaIdentityTerritory)
+ const neighborhoods = cities.length === 1 ? (data.neighborhoods ?? []) : []
+ return {
+ ...data,
+ regions,
+ cities,
+ neighborhoods,
+ startAt: data.startAt ?? null,
+ endAt: data.endAt ?? null,
+ deadline: data.deadline ?? null,
+ }
+ })
+
+export const actionPlanUpdateSchema = actionPlanFieldsSchema
+ .partial()
+ .extend({
+ id: positiveRelationshipId,
+ regions: regionsArraySchema.nullable().optional(),
+ cities: citiesArraySchema.nullable().optional(),
+ neighborhoods: neighborhoodsArraySchema.nullable().optional(),
+ locality: trimmedNullableText(160),
+ territoryNotes: trimmedNullableText(2000),
+ description: trimmedNullableText(4000),
+ startAt: z.string().datetime().nullable().optional(),
+ endAt: z.string().datetime().nullable().optional(),
+ deadline: z.string().datetime().nullable().optional(),
+ responsible: positiveRelationshipId.nullable().optional(),
+ leadership: positiveRelationshipId.nullable().optional(),
+ status: z.enum(actionPlanStatuses).optional(),
+ })
+ .superRefine((data, context) => {
+ validateTerritory(data, context, 'patch')
+ validateSchedule(data, context, 'patch')
+ })
+ .transform((data) => {
+ const cities = data.cities === undefined ? undefined : (data.cities ?? [])
+ const neighborhoods =
+ data.neighborhoods === undefined
+ ? undefined
+ : cities !== undefined && cities.length !== 1
+ ? []
+ : (data.neighborhoods ?? [])
+ const regions =
+ cities === undefined
+ ? data.regions === undefined
+ ? undefined
+ : (data.regions ?? []).filter(isBahiaIdentityTerritory)
+ : cities.length > 0
+ ? territoriesForCities(cities)
+ : data.regions === undefined
+ ? []
+ : (data.regions ?? []).filter(isBahiaIdentityTerritory)
+
+ return {
+ ...data,
+ ...(regions !== undefined ? { regions } : {}),
+ ...(cities !== undefined ? { cities } : {}),
+ ...(neighborhoods !== undefined ? { neighborhoods } : {}),
+ }
+ })
+
+export type ActionPlanCreateInput = z.input
+export type ActionPlanUpdateInput = z.input
diff --git a/src/migrations/20260718_222832_add_action_plan.json b/src/migrations/20260718_222832_add_action_plan.json
new file mode 100644
index 00000000..2a57818d
--- /dev/null
+++ b/src/migrations/20260718_222832_add_action_plan.json
@@ -0,0 +1,8606 @@
+{
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.users_sessions": {
+ "name": "users_sessions",
+ "schema": "",
+ "columns": {
+ "_order": {
+ "name": "_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "_parent_id": {
+ "name": "_parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "varchar",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "users_sessions_order_idx": {
+ "name": "users_sessions_order_idx",
+ "columns": [
+ {
+ "expression": "_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "users_sessions_parent_id_idx": {
+ "name": "users_sessions_parent_id_idx",
+ "columns": [
+ {
+ "expression": "_parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "users_sessions_parent_id_fk": {
+ "name": "users_sessions_parent_id_fk",
+ "tableFrom": "users_sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "_parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reset_password_token": {
+ "name": "reset_password_token",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reset_password_expiration": {
+ "name": "reset_password_expiration",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "salt": {
+ "name": "salt",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hash": {
+ "name": "hash",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "login_attempts": {
+ "name": "login_attempts",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "lock_until": {
+ "name": "lock_until",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "users_updated_at_idx": {
+ "name": "users_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "users_created_at_idx": {
+ "name": "users_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "users_email_idx": {
+ "name": "users_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.campaign_user_sessions": {
+ "name": "campaign_user_sessions",
+ "schema": "",
+ "columns": {
+ "_order": {
+ "name": "_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "_parent_id": {
+ "name": "_parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "varchar",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "campaign_user_sessions_order_idx": {
+ "name": "campaign_user_sessions_order_idx",
+ "columns": [
+ {
+ "expression": "_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "campaign_user_sessions_parent_id_idx": {
+ "name": "campaign_user_sessions_parent_id_idx",
+ "columns": [
+ {
+ "expression": "_parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "campaign_user_sessions_parent_id_fk": {
+ "name": "campaign_user_sessions_parent_id_fk",
+ "tableFrom": "campaign_user_sessions",
+ "tableTo": "campaign_user",
+ "columnsFrom": [
+ "_parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.campaign_user": {
+ "name": "campaign_user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "enum_campaign_user_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'lideranca'"
+ },
+ "phone": {
+ "name": "phone",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "username": {
+ "name": "username",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reset_password_token": {
+ "name": "reset_password_token",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reset_password_expiration": {
+ "name": "reset_password_expiration",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "salt": {
+ "name": "salt",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hash": {
+ "name": "hash",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "login_attempts": {
+ "name": "login_attempts",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "lock_until": {
+ "name": "lock_until",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "campaign_user_phone_idx": {
+ "name": "campaign_user_phone_idx",
+ "columns": [
+ {
+ "expression": "phone",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "campaign_user_updated_at_idx": {
+ "name": "campaign_user_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "campaign_user_created_at_idx": {
+ "name": "campaign_user_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "campaign_user_email_idx": {
+ "name": "campaign_user_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "campaign_user_username_idx": {
+ "name": "campaign_user_username_idx",
+ "columns": [
+ {
+ "expression": "username",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.campaign_invite": {
+ "name": "campaign_invite",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "leadership_id": {
+ "name": "leadership_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "enum_campaign_invite_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "campaign_invite_token_hash_idx": {
+ "name": "campaign_invite_token_hash_idx",
+ "columns": [
+ {
+ "expression": "token_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "campaign_invite_leadership_idx": {
+ "name": "campaign_invite_leadership_idx",
+ "columns": [
+ {
+ "expression": "leadership_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "campaign_invite_kind_idx": {
+ "name": "campaign_invite_kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "campaign_invite_expires_at_idx": {
+ "name": "campaign_invite_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "campaign_invite_used_at_idx": {
+ "name": "campaign_invite_used_at_idx",
+ "columns": [
+ {
+ "expression": "used_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "campaign_invite_revoked_at_idx": {
+ "name": "campaign_invite_revoked_at_idx",
+ "columns": [
+ {
+ "expression": "revoked_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "campaign_invite_created_by_idx": {
+ "name": "campaign_invite_created_by_idx",
+ "columns": [
+ {
+ "expression": "created_by_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "campaign_invite_updated_at_idx": {
+ "name": "campaign_invite_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "campaign_invite_created_at_idx": {
+ "name": "campaign_invite_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "leadership_kind_idx": {
+ "name": "leadership_kind_idx",
+ "columns": [
+ {
+ "expression": "leadership_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "campaign_invite_leadership_id_leadership_id_fk": {
+ "name": "campaign_invite_leadership_id_leadership_id_fk",
+ "tableFrom": "campaign_invite",
+ "tableTo": "leadership",
+ "columnsFrom": [
+ "leadership_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "campaign_invite_created_by_id_campaign_user_id_fk": {
+ "name": "campaign_invite_created_by_id_campaign_user_id_fk",
+ "tableFrom": "campaign_invite",
+ "tableTo": "campaign_user",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.electoral_nucleus_tse_zones": {
+ "name": "electoral_nucleus_tse_zones",
+ "schema": "",
+ "columns": {
+ "_order": {
+ "name": "_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "_parent_id": {
+ "name": "_parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "varchar",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "zone_number": {
+ "name": "zone_number",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "electoral_nucleus_tse_zones_order_idx": {
+ "name": "electoral_nucleus_tse_zones_order_idx",
+ "columns": [
+ {
+ "expression": "_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_tse_zones_parent_id_idx": {
+ "name": "electoral_nucleus_tse_zones_parent_id_idx",
+ "columns": [
+ {
+ "expression": "_parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "electoral_nucleus_tse_zones_parent_id_fk": {
+ "name": "electoral_nucleus_tse_zones_parent_id_fk",
+ "tableFrom": "electoral_nucleus_tse_zones",
+ "tableTo": "electoral_nucleus",
+ "columnsFrom": [
+ "_parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.electoral_nucleus_voter_profiles": {
+ "name": "electoral_nucleus_voter_profiles",
+ "schema": "",
+ "columns": {
+ "_order": {
+ "name": "_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "_parent_id": {
+ "name": "_parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "varchar",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "age_range": {
+ "name": "age_range",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "income_band": {
+ "name": "income_band",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "occupation": {
+ "name": "occupation",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "local_traits": {
+ "name": "local_traits",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "electoral_nucleus_voter_profiles_order_idx": {
+ "name": "electoral_nucleus_voter_profiles_order_idx",
+ "columns": [
+ {
+ "expression": "_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_voter_profiles_parent_id_idx": {
+ "name": "electoral_nucleus_voter_profiles_parent_id_idx",
+ "columns": [
+ {
+ "expression": "_parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "electoral_nucleus_voter_profiles_parent_id_fk": {
+ "name": "electoral_nucleus_voter_profiles_parent_id_fk",
+ "tableFrom": "electoral_nucleus_voter_profiles",
+ "tableTo": "electoral_nucleus",
+ "columnsFrom": [
+ "_parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.electoral_nucleus_strengths": {
+ "name": "electoral_nucleus_strengths",
+ "schema": "",
+ "columns": {
+ "_order": {
+ "name": "_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "_parent_id": {
+ "name": "_parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "varchar",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "text": {
+ "name": "text",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "electoral_nucleus_strengths_order_idx": {
+ "name": "electoral_nucleus_strengths_order_idx",
+ "columns": [
+ {
+ "expression": "_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_strengths_parent_id_idx": {
+ "name": "electoral_nucleus_strengths_parent_id_idx",
+ "columns": [
+ {
+ "expression": "_parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "electoral_nucleus_strengths_parent_id_fk": {
+ "name": "electoral_nucleus_strengths_parent_id_fk",
+ "tableFrom": "electoral_nucleus_strengths",
+ "tableTo": "electoral_nucleus",
+ "columnsFrom": [
+ "_parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.electoral_nucleus_risks": {
+ "name": "electoral_nucleus_risks",
+ "schema": "",
+ "columns": {
+ "_order": {
+ "name": "_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "_parent_id": {
+ "name": "_parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "varchar",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "text": {
+ "name": "text",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "electoral_nucleus_risks_order_idx": {
+ "name": "electoral_nucleus_risks_order_idx",
+ "columns": [
+ {
+ "expression": "_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_risks_parent_id_idx": {
+ "name": "electoral_nucleus_risks_parent_id_idx",
+ "columns": [
+ {
+ "expression": "_parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "electoral_nucleus_risks_parent_id_fk": {
+ "name": "electoral_nucleus_risks_parent_id_fk",
+ "tableFrom": "electoral_nucleus_risks",
+ "tableTo": "electoral_nucleus",
+ "columnsFrom": [
+ "_parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.electoral_nucleus": {
+ "name": "electoral_nucleus",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "enum_electoral_nucleus_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ativo'"
+ },
+ "locality": {
+ "name": "locality",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "territory_notes": {
+ "name": "territory_notes",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "organization_kind": {
+ "name": "organization_kind",
+ "type": "enum_electoral_nucleus_organization_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'territorial'"
+ },
+ "organization_label": {
+ "name": "organization_label",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sector_kind": {
+ "name": "sector_kind",
+ "type": "enum_electoral_nucleus_sector_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "primary_contact_id": {
+ "name": "primary_contact_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmed_vote_estimate": {
+ "name": "confirmed_vote_estimate",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmed_vote_estimate_at": {
+ "name": "confirmed_vote_estimate_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmed_vote_estimate_by_id": {
+ "name": "confirmed_vote_estimate_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmation_note": {
+ "name": "confirmation_note",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proposed_vote_estimate": {
+ "name": "proposed_vote_estimate",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proposed_vote_estimate_at": {
+ "name": "proposed_vote_estimate_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proposed_vote_estimate_by_id": {
+ "name": "proposed_vote_estimate_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proposed_vote_estimate_version": {
+ "name": "proposed_vote_estimate_version",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ticket_alliance_partner_name": {
+ "name": "ticket_alliance_partner_name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ticket_alliance_office": {
+ "name": "ticket_alliance_office",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ticket_alliance_is_campaign_partner": {
+ "name": "ticket_alliance_is_campaign_partner",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "ticket_alliance_notes": {
+ "name": "ticket_alliance_notes",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_update_at": {
+ "name": "last_update_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "electoral_nucleus_name_idx": {
+ "name": "electoral_nucleus_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_slug_idx": {
+ "name": "electoral_nucleus_slug_idx",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_status_idx": {
+ "name": "electoral_nucleus_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_organization_kind_idx": {
+ "name": "electoral_nucleus_organization_kind_idx",
+ "columns": [
+ {
+ "expression": "organization_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_sector_kind_idx": {
+ "name": "electoral_nucleus_sector_kind_idx",
+ "columns": [
+ {
+ "expression": "sector_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_primary_contact_idx": {
+ "name": "electoral_nucleus_primary_contact_idx",
+ "columns": [
+ {
+ "expression": "primary_contact_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_confirmed_vote_estimate_idx": {
+ "name": "electoral_nucleus_confirmed_vote_estimate_idx",
+ "columns": [
+ {
+ "expression": "confirmed_vote_estimate",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_confirmed_vote_estimate_by_idx": {
+ "name": "electoral_nucleus_confirmed_vote_estimate_by_idx",
+ "columns": [
+ {
+ "expression": "confirmed_vote_estimate_by_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_proposed_vote_estimate_idx": {
+ "name": "electoral_nucleus_proposed_vote_estimate_idx",
+ "columns": [
+ {
+ "expression": "proposed_vote_estimate",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_proposed_vote_estimate_by_idx": {
+ "name": "electoral_nucleus_proposed_vote_estimate_by_idx",
+ "columns": [
+ {
+ "expression": "proposed_vote_estimate_by_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_proposed_vote_estimate_version_idx": {
+ "name": "electoral_nucleus_proposed_vote_estimate_version_idx",
+ "columns": [
+ {
+ "expression": "proposed_vote_estimate_version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_last_update_at_idx": {
+ "name": "electoral_nucleus_last_update_at_idx",
+ "columns": [
+ {
+ "expression": "last_update_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_created_by_idx": {
+ "name": "electoral_nucleus_created_by_idx",
+ "columns": [
+ {
+ "expression": "created_by_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_updated_at_idx": {
+ "name": "electoral_nucleus_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_created_at_idx": {
+ "name": "electoral_nucleus_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "electoral_nucleus_primary_contact_id_contact_id_fk": {
+ "name": "electoral_nucleus_primary_contact_id_contact_id_fk",
+ "tableFrom": "electoral_nucleus",
+ "tableTo": "contact",
+ "columnsFrom": [
+ "primary_contact_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "electoral_nucleus_confirmed_vote_estimate_by_id_campaign_user_id_fk": {
+ "name": "electoral_nucleus_confirmed_vote_estimate_by_id_campaign_user_id_fk",
+ "tableFrom": "electoral_nucleus",
+ "tableTo": "campaign_user",
+ "columnsFrom": [
+ "confirmed_vote_estimate_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "electoral_nucleus_proposed_vote_estimate_by_id_campaign_user_id_fk": {
+ "name": "electoral_nucleus_proposed_vote_estimate_by_id_campaign_user_id_fk",
+ "tableFrom": "electoral_nucleus",
+ "tableTo": "campaign_user",
+ "columnsFrom": [
+ "proposed_vote_estimate_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "electoral_nucleus_created_by_id_campaign_user_id_fk": {
+ "name": "electoral_nucleus_created_by_id_campaign_user_id_fk",
+ "tableFrom": "electoral_nucleus",
+ "tableTo": "campaign_user",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.electoral_nucleus_texts": {
+ "name": "electoral_nucleus_texts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "path": {
+ "name": "path",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "text": {
+ "name": "text",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "electoral_nucleus_texts_order_parent": {
+ "name": "electoral_nucleus_texts_order_parent",
+ "columns": [
+ {
+ "expression": "order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_texts_text_idx": {
+ "name": "electoral_nucleus_texts_text_idx",
+ "columns": [
+ {
+ "expression": "text",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "electoral_nucleus_texts_parent_fk": {
+ "name": "electoral_nucleus_texts_parent_fk",
+ "tableFrom": "electoral_nucleus_texts",
+ "tableTo": "electoral_nucleus",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.electoral_nucleus_rels": {
+ "name": "electoral_nucleus_rels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "path": {
+ "name": "path",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "campaign_user_id": {
+ "name": "campaign_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "electoral_nucleus_rels_order_idx": {
+ "name": "electoral_nucleus_rels_order_idx",
+ "columns": [
+ {
+ "expression": "order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_rels_parent_idx": {
+ "name": "electoral_nucleus_rels_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_rels_path_idx": {
+ "name": "electoral_nucleus_rels_path_idx",
+ "columns": [
+ {
+ "expression": "path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "electoral_nucleus_rels_campaign_user_id_idx": {
+ "name": "electoral_nucleus_rels_campaign_user_id_idx",
+ "columns": [
+ {
+ "expression": "campaign_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "electoral_nucleus_rels_parent_fk": {
+ "name": "electoral_nucleus_rels_parent_fk",
+ "tableFrom": "electoral_nucleus_rels",
+ "tableTo": "electoral_nucleus",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "electoral_nucleus_rels_campaign_user_fk": {
+ "name": "electoral_nucleus_rels_campaign_user_fk",
+ "tableFrom": "electoral_nucleus_rels",
+ "tableTo": "campaign_user",
+ "columnsFrom": [
+ "campaign_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.leadership": {
+ "name": "leadership",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "nucleus_id": {
+ "name": "nucleus_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sector": {
+ "name": "sector",
+ "type": "enum_leadership_sector",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sector_notes": {
+ "name": "sector_notes",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "support_status": {
+ "name": "support_status",
+ "type": "enum_leadership_support_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'a_abordar'"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consent_id": {
+ "name": "consent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consent_content_hash": {
+ "name": "consent_content_hash",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consented_at": {
+ "name": "consented_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consent_note": {
+ "name": "consent_note",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "leadership_contact_idx": {
+ "name": "leadership_contact_idx",
+ "columns": [
+ {
+ "expression": "contact_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "leadership_nucleus_idx": {
+ "name": "leadership_nucleus_idx",
+ "columns": [
+ {
+ "expression": "nucleus_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "leadership_sector_idx": {
+ "name": "leadership_sector_idx",
+ "columns": [
+ {
+ "expression": "sector",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "leadership_support_status_idx": {
+ "name": "leadership_support_status_idx",
+ "columns": [
+ {
+ "expression": "support_status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "leadership_user_idx": {
+ "name": "leadership_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "leadership_consent_idx": {
+ "name": "leadership_consent_idx",
+ "columns": [
+ {
+ "expression": "consent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "leadership_created_by_idx": {
+ "name": "leadership_created_by_idx",
+ "columns": [
+ {
+ "expression": "created_by_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "leadership_updated_at_idx": {
+ "name": "leadership_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "leadership_created_at_idx": {
+ "name": "leadership_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "contact_nucleus_idx": {
+ "name": "contact_nucleus_idx",
+ "columns": [
+ {
+ "expression": "contact_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "nucleus_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "leadership_contact_id_contact_id_fk": {
+ "name": "leadership_contact_id_contact_id_fk",
+ "tableFrom": "leadership",
+ "tableTo": "contact",
+ "columnsFrom": [
+ "contact_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "leadership_nucleus_id_electoral_nucleus_id_fk": {
+ "name": "leadership_nucleus_id_electoral_nucleus_id_fk",
+ "tableFrom": "leadership",
+ "tableTo": "electoral_nucleus",
+ "columnsFrom": [
+ "nucleus_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "leadership_user_id_campaign_user_id_fk": {
+ "name": "leadership_user_id_campaign_user_id_fk",
+ "tableFrom": "leadership",
+ "tableTo": "campaign_user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "leadership_consent_id_consent_id_fk": {
+ "name": "leadership_consent_id_consent_id_fk",
+ "tableFrom": "leadership",
+ "tableTo": "consent",
+ "columnsFrom": [
+ "consent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "leadership_created_by_id_campaign_user_id_fk": {
+ "name": "leadership_created_by_id_campaign_user_id_fk",
+ "tableFrom": "leadership",
+ "tableTo": "campaign_user",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.nucleus_update": {
+ "name": "nucleus_update",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "nucleus_id": {
+ "name": "nucleus_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "enum_nucleus_update_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'semanal'"
+ },
+ "worked": {
+ "name": "worked",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "failed": {
+ "name": "failed",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "needs": {
+ "name": "needs",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "active_volunteers": {
+ "name": "active_volunteers",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "new_supports": {
+ "name": "new_supports",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body": {
+ "name": "body",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "nucleus_update_nucleus_idx": {
+ "name": "nucleus_update_nucleus_idx",
+ "columns": [
+ {
+ "expression": "nucleus_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "nucleus_update_author_idx": {
+ "name": "nucleus_update_author_idx",
+ "columns": [
+ {
+ "expression": "author_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "nucleus_update_kind_idx": {
+ "name": "nucleus_update_kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "nucleus_update_updated_at_idx": {
+ "name": "nucleus_update_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "nucleus_update_created_at_idx": {
+ "name": "nucleus_update_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "nucleus_update_nucleus_id_electoral_nucleus_id_fk": {
+ "name": "nucleus_update_nucleus_id_electoral_nucleus_id_fk",
+ "tableFrom": "nucleus_update",
+ "tableTo": "electoral_nucleus",
+ "columnsFrom": [
+ "nucleus_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "nucleus_update_author_id_campaign_user_id_fk": {
+ "name": "nucleus_update_author_id_campaign_user_id_fk",
+ "tableFrom": "nucleus_update",
+ "tableTo": "campaign_user",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.action_plan_tasks": {
+ "name": "action_plan_tasks",
+ "schema": "",
+ "columns": {
+ "_order": {
+ "name": "_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "_parent_id": {
+ "name": "_parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "varchar",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "responsible_id": {
+ "name": "responsible_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "due": {
+ "name": "due",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "done": {
+ "name": "done",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "done_at": {
+ "name": "done_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "action_plan_tasks_order_idx": {
+ "name": "action_plan_tasks_order_idx",
+ "columns": [
+ {
+ "expression": "_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_tasks_parent_id_idx": {
+ "name": "action_plan_tasks_parent_id_idx",
+ "columns": [
+ {
+ "expression": "_parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_tasks_responsible_idx": {
+ "name": "action_plan_tasks_responsible_idx",
+ "columns": [
+ {
+ "expression": "responsible_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "action_plan_tasks_responsible_id_contact_id_fk": {
+ "name": "action_plan_tasks_responsible_id_contact_id_fk",
+ "tableFrom": "action_plan_tasks",
+ "tableTo": "contact",
+ "columnsFrom": [
+ "responsible_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "action_plan_tasks_parent_id_fk": {
+ "name": "action_plan_tasks_parent_id_fk",
+ "tableFrom": "action_plan_tasks",
+ "tableTo": "action_plan",
+ "columnsFrom": [
+ "_parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.action_plan_updates": {
+ "name": "action_plan_updates",
+ "schema": "",
+ "columns": {
+ "_order": {
+ "name": "_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "_parent_id": {
+ "name": "_parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "varchar",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "action_plan_updates_order_idx": {
+ "name": "action_plan_updates_order_idx",
+ "columns": [
+ {
+ "expression": "_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_updates_parent_id_idx": {
+ "name": "action_plan_updates_parent_id_idx",
+ "columns": [
+ {
+ "expression": "_parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_updates_author_idx": {
+ "name": "action_plan_updates_author_idx",
+ "columns": [
+ {
+ "expression": "author_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "action_plan_updates_author_id_campaign_user_id_fk": {
+ "name": "action_plan_updates_author_id_campaign_user_id_fk",
+ "tableFrom": "action_plan_updates",
+ "tableTo": "campaign_user",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "action_plan_updates_parent_id_fk": {
+ "name": "action_plan_updates_parent_id_fk",
+ "tableFrom": "action_plan_updates",
+ "tableTo": "action_plan",
+ "columnsFrom": [
+ "_parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.action_plan": {
+ "name": "action_plan",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "enum_action_plan_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "enum_action_plan_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'rascunho'"
+ },
+ "description": {
+ "name": "description",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "start_at": {
+ "name": "start_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "end_at": {
+ "name": "end_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deadline": {
+ "name": "deadline",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "locality": {
+ "name": "locality",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "territory_notes": {
+ "name": "territory_notes",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "responsible_id": {
+ "name": "responsible_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "leadership_id": {
+ "name": "leadership_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "action_plan_title_idx": {
+ "name": "action_plan_title_idx",
+ "columns": [
+ {
+ "expression": "title",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_slug_idx": {
+ "name": "action_plan_slug_idx",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_kind_idx": {
+ "name": "action_plan_kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_status_idx": {
+ "name": "action_plan_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_start_at_idx": {
+ "name": "action_plan_start_at_idx",
+ "columns": [
+ {
+ "expression": "start_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_responsible_idx": {
+ "name": "action_plan_responsible_idx",
+ "columns": [
+ {
+ "expression": "responsible_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_leadership_idx": {
+ "name": "action_plan_leadership_idx",
+ "columns": [
+ {
+ "expression": "leadership_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_created_by_idx": {
+ "name": "action_plan_created_by_idx",
+ "columns": [
+ {
+ "expression": "created_by_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_updated_at_idx": {
+ "name": "action_plan_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_created_at_idx": {
+ "name": "action_plan_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "action_plan_responsible_id_contact_id_fk": {
+ "name": "action_plan_responsible_id_contact_id_fk",
+ "tableFrom": "action_plan",
+ "tableTo": "contact",
+ "columnsFrom": [
+ "responsible_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "action_plan_leadership_id_leadership_id_fk": {
+ "name": "action_plan_leadership_id_leadership_id_fk",
+ "tableFrom": "action_plan",
+ "tableTo": "leadership",
+ "columnsFrom": [
+ "leadership_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "action_plan_created_by_id_campaign_user_id_fk": {
+ "name": "action_plan_created_by_id_campaign_user_id_fk",
+ "tableFrom": "action_plan",
+ "tableTo": "campaign_user",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.action_plan_texts": {
+ "name": "action_plan_texts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "path": {
+ "name": "path",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "text": {
+ "name": "text",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "action_plan_texts_order_parent": {
+ "name": "action_plan_texts_order_parent",
+ "columns": [
+ {
+ "expression": "order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_texts_text_idx": {
+ "name": "action_plan_texts_text_idx",
+ "columns": [
+ {
+ "expression": "text",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "action_plan_texts_parent_fk": {
+ "name": "action_plan_texts_parent_fk",
+ "tableFrom": "action_plan_texts",
+ "tableTo": "action_plan",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.action_plan_rels": {
+ "name": "action_plan_rels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "path": {
+ "name": "path",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "campaign_user_id": {
+ "name": "campaign_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "action_plan_rels_order_idx": {
+ "name": "action_plan_rels_order_idx",
+ "columns": [
+ {
+ "expression": "order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_rels_parent_idx": {
+ "name": "action_plan_rels_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_rels_path_idx": {
+ "name": "action_plan_rels_path_idx",
+ "columns": [
+ {
+ "expression": "path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_plan_rels_campaign_user_id_idx": {
+ "name": "action_plan_rels_campaign_user_id_idx",
+ "columns": [
+ {
+ "expression": "campaign_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "action_plan_rels_parent_fk": {
+ "name": "action_plan_rels_parent_fk",
+ "tableFrom": "action_plan_rels",
+ "tableTo": "action_plan",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "action_plan_rels_campaign_user_fk": {
+ "name": "action_plan_rels_campaign_user_fk",
+ "tableFrom": "action_plan_rels",
+ "tableTo": "campaign_user",
+ "columnsFrom": [
+ "campaign_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.election_tally": {
+ "name": "election_tally",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "year": {
+ "name": "year",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "office": {
+ "name": "office",
+ "type": "enum_election_tally_office",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "turn": {
+ "name": "turn",
+ "type": "enum_election_tally_turn",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'BA'"
+ },
+ "city_code": {
+ "name": "city_code",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "city_name": {
+ "name": "city_name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "zone_number": {
+ "name": "zone_number",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "aptos": {
+ "name": "aptos",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "comparecimento": {
+ "name": "comparecimento",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "abstencoes": {
+ "name": "abstencoes",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "votos_validos": {
+ "name": "votos_validos",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "votos_nominais_validos": {
+ "name": "votos_nominais_validos",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "votos_legenda": {
+ "name": "votos_legenda",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "votos_branco": {
+ "name": "votos_branco",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "votos_nulo": {
+ "name": "votos_nulo",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "votos_anulados": {
+ "name": "votos_anulados",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "winner_candidate_number": {
+ "name": "winner_candidate_number",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "winner_candidate_name": {
+ "name": "winner_candidate_name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "winner_votes": {
+ "name": "winner_votes",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "winner_coalition": {
+ "name": "winner_coalition",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "winner_party": {
+ "name": "winner_party",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "election_tally_year_idx": {
+ "name": "election_tally_year_idx",
+ "columns": [
+ {
+ "expression": "year",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_tally_office_idx": {
+ "name": "election_tally_office_idx",
+ "columns": [
+ {
+ "expression": "office",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_tally_turn_idx": {
+ "name": "election_tally_turn_idx",
+ "columns": [
+ {
+ "expression": "turn",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_tally_state_idx": {
+ "name": "election_tally_state_idx",
+ "columns": [
+ {
+ "expression": "state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_tally_city_code_idx": {
+ "name": "election_tally_city_code_idx",
+ "columns": [
+ {
+ "expression": "city_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_tally_city_name_idx": {
+ "name": "election_tally_city_name_idx",
+ "columns": [
+ {
+ "expression": "city_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_tally_zone_number_idx": {
+ "name": "election_tally_zone_number_idx",
+ "columns": [
+ {
+ "expression": "zone_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_tally_updated_at_idx": {
+ "name": "election_tally_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_tally_created_at_idx": {
+ "name": "election_tally_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "year_office_turn_state_cityCode_zoneNumber_idx": {
+ "name": "year_office_turn_state_cityCode_zoneNumber_idx",
+ "columns": [
+ {
+ "expression": "year",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "office",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "turn",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "city_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "zone_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.election_candidate_vote": {
+ "name": "election_candidate_vote",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "year": {
+ "name": "year",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "office": {
+ "name": "office",
+ "type": "enum_election_candidate_vote_office",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "turn": {
+ "name": "turn",
+ "type": "enum_election_candidate_vote_turn",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'BA'"
+ },
+ "city_code": {
+ "name": "city_code",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "city_name": {
+ "name": "city_name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "zone_number": {
+ "name": "zone_number",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "candidate_number": {
+ "name": "candidate_number",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "candidate_name": {
+ "name": "candidate_name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "coalition": {
+ "name": "coalition",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "party": {
+ "name": "party",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "vote_type": {
+ "name": "vote_type",
+ "type": "enum_election_candidate_vote_vote_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'nominal'"
+ },
+ "votes": {
+ "name": "votes",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "election_candidate_vote_year_idx": {
+ "name": "election_candidate_vote_year_idx",
+ "columns": [
+ {
+ "expression": "year",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_vote_office_idx": {
+ "name": "election_candidate_vote_office_idx",
+ "columns": [
+ {
+ "expression": "office",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_vote_turn_idx": {
+ "name": "election_candidate_vote_turn_idx",
+ "columns": [
+ {
+ "expression": "turn",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_vote_state_idx": {
+ "name": "election_candidate_vote_state_idx",
+ "columns": [
+ {
+ "expression": "state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_vote_city_code_idx": {
+ "name": "election_candidate_vote_city_code_idx",
+ "columns": [
+ {
+ "expression": "city_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_vote_city_name_idx": {
+ "name": "election_candidate_vote_city_name_idx",
+ "columns": [
+ {
+ "expression": "city_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_vote_zone_number_idx": {
+ "name": "election_candidate_vote_zone_number_idx",
+ "columns": [
+ {
+ "expression": "zone_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_vote_candidate_number_idx": {
+ "name": "election_candidate_vote_candidate_number_idx",
+ "columns": [
+ {
+ "expression": "candidate_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_vote_vote_type_idx": {
+ "name": "election_candidate_vote_vote_type_idx",
+ "columns": [
+ {
+ "expression": "vote_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_vote_updated_at_idx": {
+ "name": "election_candidate_vote_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_vote_created_at_idx": {
+ "name": "election_candidate_vote_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "compound_index_idx": {
+ "name": "compound_index_idx",
+ "columns": [
+ {
+ "expression": "year",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "office",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "turn",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "city_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "zone_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "candidate_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "vote_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.election_candidate": {
+ "name": "election_candidate",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "year": {
+ "name": "year",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "office": {
+ "name": "office",
+ "type": "enum_election_candidate_office",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "turn": {
+ "name": "turn",
+ "type": "enum_election_candidate_turn",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'BA'"
+ },
+ "candidate_number": {
+ "name": "candidate_number",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "urna_name": {
+ "name": "urna_name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "complete_name": {
+ "name": "complete_name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "party": {
+ "name": "party",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "coalition": {
+ "name": "coalition",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "candidate_status": {
+ "name": "candidate_status",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "elected": {
+ "name": "elected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "elected_by": {
+ "name": "elected_by",
+ "type": "enum_election_candidate_elected_by",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_votes_state": {
+ "name": "total_votes_state",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "identity_key": {
+ "name": "identity_key",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "running_again2026": {
+ "name": "running_again2026",
+ "type": "enum_election_candidate_running_again2026",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'desconhecido'"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "election_candidate_year_idx": {
+ "name": "election_candidate_year_idx",
+ "columns": [
+ {
+ "expression": "year",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_office_idx": {
+ "name": "election_candidate_office_idx",
+ "columns": [
+ {
+ "expression": "office",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_turn_idx": {
+ "name": "election_candidate_turn_idx",
+ "columns": [
+ {
+ "expression": "turn",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_state_idx": {
+ "name": "election_candidate_state_idx",
+ "columns": [
+ {
+ "expression": "state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_candidate_number_idx": {
+ "name": "election_candidate_candidate_number_idx",
+ "columns": [
+ {
+ "expression": "candidate_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_elected_idx": {
+ "name": "election_candidate_elected_idx",
+ "columns": [
+ {
+ "expression": "elected",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_identity_key_idx": {
+ "name": "election_candidate_identity_key_idx",
+ "columns": [
+ {
+ "expression": "identity_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_updated_at_idx": {
+ "name": "election_candidate_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "election_candidate_created_at_idx": {
+ "name": "election_candidate_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "year_office_turn_state_candidateNumber_idx": {
+ "name": "year_office_turn_state_candidateNumber_idx",
+ "columns": [
+ {
+ "expression": "year",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "office",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "turn",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "candidate_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media": {
+ "name": "media",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "alt": {
+ "name": "alt",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "url": {
+ "name": "url",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "thumbnail_u_r_l": {
+ "name": "thumbnail_u_r_l",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "filesize": {
+ "name": "filesize",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "width": {
+ "name": "width",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "height": {
+ "name": "height",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "focal_x": {
+ "name": "focal_x",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "focal_y": {
+ "name": "focal_y",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "media_updated_at_idx": {
+ "name": "media_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "media_created_at_idx": {
+ "name": "media_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "media_filename_idx": {
+ "name": "media_filename_idx",
+ "columns": [
+ {
+ "expression": "filename",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.petition": {
+ "name": "petition",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subtitle": {
+ "name": "subtitle",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "body": {
+ "name": "body",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "form_title": {
+ "name": "form_title",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "form_subtitle": {
+ "name": "form_subtitle",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "form_consent_id": {
+ "name": "form_consent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "_status": {
+ "name": "_status",
+ "type": "enum_petition_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'draft'"
+ }
+ },
+ "indexes": {
+ "petition_form_form_consent_idx": {
+ "name": "petition_form_form_consent_idx",
+ "columns": [
+ {
+ "expression": "form_consent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "petition_updated_at_idx": {
+ "name": "petition_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "petition_created_at_idx": {
+ "name": "petition_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "petition__status_idx": {
+ "name": "petition__status_idx",
+ "columns": [
+ {
+ "expression": "_status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "petition_form_consent_id_consent_id_fk": {
+ "name": "petition_form_consent_id_consent_id_fk",
+ "tableFrom": "petition",
+ "tableTo": "consent",
+ "columnsFrom": [
+ "form_consent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public._petition_v": {
+ "name": "_petition_v",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_title": {
+ "name": "version_title",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_subtitle": {
+ "name": "version_subtitle",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_enabled": {
+ "name": "version_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "version_body": {
+ "name": "version_body",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_form_title": {
+ "name": "version_form_title",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_form_subtitle": {
+ "name": "version_form_subtitle",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_form_consent_id": {
+ "name": "version_form_consent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_updated_at": {
+ "name": "version_updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_created_at": {
+ "name": "version_created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version__status": {
+ "name": "version__status",
+ "type": "enum__petition_v_version_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'draft'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "latest": {
+ "name": "latest",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "_petition_v_parent_idx": {
+ "name": "_petition_v_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_petition_v_version_form_version_form_consent_idx": {
+ "name": "_petition_v_version_form_version_form_consent_idx",
+ "columns": [
+ {
+ "expression": "version_form_consent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_petition_v_version_version_updated_at_idx": {
+ "name": "_petition_v_version_version_updated_at_idx",
+ "columns": [
+ {
+ "expression": "version_updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_petition_v_version_version_created_at_idx": {
+ "name": "_petition_v_version_version_created_at_idx",
+ "columns": [
+ {
+ "expression": "version_created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_petition_v_version_version__status_idx": {
+ "name": "_petition_v_version_version__status_idx",
+ "columns": [
+ {
+ "expression": "version__status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_petition_v_created_at_idx": {
+ "name": "_petition_v_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_petition_v_updated_at_idx": {
+ "name": "_petition_v_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_petition_v_latest_idx": {
+ "name": "_petition_v_latest_idx",
+ "columns": [
+ {
+ "expression": "latest",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "_petition_v_parent_id_petition_id_fk": {
+ "name": "_petition_v_parent_id_petition_id_fk",
+ "tableFrom": "_petition_v",
+ "tableTo": "petition",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "_petition_v_version_form_consent_id_consent_id_fk": {
+ "name": "_petition_v_version_form_consent_id_consent_id_fk",
+ "tableFrom": "_petition_v",
+ "tableTo": "consent",
+ "columnsFrom": [
+ "version_form_consent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contact": {
+ "name": "contact",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "gender": {
+ "name": "gender",
+ "type": "enum_contact_gender",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "state": {
+ "name": "state",
+ "type": "enum_contact_state",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "city": {
+ "name": "city",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "postal_code": {
+ "name": "postal_code",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "contact_phone_idx": {
+ "name": "contact_phone_idx",
+ "columns": [
+ {
+ "expression": "phone",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "contact_updated_at_idx": {
+ "name": "contact_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "contact_created_at_idx": {
+ "name": "contact_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.consent": {
+ "name": "consent",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "text": {
+ "name": "text",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "consent_key_idx": {
+ "name": "consent_key_idx",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "consent_updated_at_idx": {
+ "name": "consent_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "consent_created_at_idx": {
+ "name": "consent_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.signature": {
+ "name": "signature",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "petition_id": {
+ "name": "petition_id",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "consent_id": {
+ "name": "consent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "signature_contact_idx": {
+ "name": "signature_contact_idx",
+ "columns": [
+ {
+ "expression": "contact_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "signature_petition_idx": {
+ "name": "signature_petition_idx",
+ "columns": [
+ {
+ "expression": "petition_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "signature_consent_idx": {
+ "name": "signature_consent_idx",
+ "columns": [
+ {
+ "expression": "consent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "signature_updated_at_idx": {
+ "name": "signature_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "signature_created_at_idx": {
+ "name": "signature_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "signature_contact_id_contact_id_fk": {
+ "name": "signature_contact_id_contact_id_fk",
+ "tableFrom": "signature",
+ "tableTo": "contact",
+ "columnsFrom": [
+ "contact_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "signature_petition_id_petition_id_fk": {
+ "name": "signature_petition_id_petition_id_fk",
+ "tableFrom": "signature",
+ "tableTo": "petition",
+ "columnsFrom": [
+ "petition_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "signature_consent_id_consent_id_fk": {
+ "name": "signature_consent_id_consent_id_fk",
+ "tableFrom": "signature",
+ "tableTo": "consent",
+ "columnsFrom": [
+ "consent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.subscription": {
+ "name": "subscription",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "consent_id": {
+ "name": "consent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "subscription_contact_idx": {
+ "name": "subscription_contact_idx",
+ "columns": [
+ {
+ "expression": "contact_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "subscription_consent_idx": {
+ "name": "subscription_consent_idx",
+ "columns": [
+ {
+ "expression": "consent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "subscription_updated_at_idx": {
+ "name": "subscription_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "subscription_created_at_idx": {
+ "name": "subscription_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "subscription_contact_id_contact_id_fk": {
+ "name": "subscription_contact_id_contact_id_fk",
+ "tableFrom": "subscription",
+ "tableTo": "contact",
+ "columnsFrom": [
+ "contact_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "subscription_consent_id_consent_id_fk": {
+ "name": "subscription_consent_id_consent_id_fk",
+ "tableFrom": "subscription",
+ "tableTo": "consent",
+ "columnsFrom": [
+ "consent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.post": {
+ "name": "post",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "enum_post_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subtitle": {
+ "name": "subtitle",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_image_id": {
+ "name": "cover_image_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_date": {
+ "name": "published_date",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body": {
+ "name": "body",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "_status": {
+ "name": "_status",
+ "type": "enum_post_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'draft'"
+ }
+ },
+ "indexes": {
+ "post_slug_idx": {
+ "name": "post_slug_idx",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "post_category_idx": {
+ "name": "post_category_idx",
+ "columns": [
+ {
+ "expression": "category_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "post_cover_image_idx": {
+ "name": "post_cover_image_idx",
+ "columns": [
+ {
+ "expression": "cover_image_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "post_updated_at_idx": {
+ "name": "post_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "post_created_at_idx": {
+ "name": "post_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "post__status_idx": {
+ "name": "post__status_idx",
+ "columns": [
+ {
+ "expression": "_status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "post_category_id_tag_id_fk": {
+ "name": "post_category_id_tag_id_fk",
+ "tableFrom": "post",
+ "tableTo": "tag",
+ "columnsFrom": [
+ "category_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "post_cover_image_id_media_id_fk": {
+ "name": "post_cover_image_id_media_id_fk",
+ "tableFrom": "post",
+ "tableTo": "media",
+ "columnsFrom": [
+ "cover_image_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.post_rels": {
+ "name": "post_rels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "path": {
+ "name": "path",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "post_rels_order_idx": {
+ "name": "post_rels_order_idx",
+ "columns": [
+ {
+ "expression": "order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "post_rels_parent_idx": {
+ "name": "post_rels_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "post_rels_path_idx": {
+ "name": "post_rels_path_idx",
+ "columns": [
+ {
+ "expression": "path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "post_rels_tag_id_idx": {
+ "name": "post_rels_tag_id_idx",
+ "columns": [
+ {
+ "expression": "tag_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "post_rels_parent_fk": {
+ "name": "post_rels_parent_fk",
+ "tableFrom": "post_rels",
+ "tableTo": "post",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "post_rels_tag_fk": {
+ "name": "post_rels_tag_fk",
+ "tableFrom": "post_rels",
+ "tableTo": "tag",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public._post_v": {
+ "name": "_post_v",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_title": {
+ "name": "version_title",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_slug": {
+ "name": "version_slug",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_type": {
+ "name": "version_type",
+ "type": "enum__post_v_version_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_category_id": {
+ "name": "version_category_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_subtitle": {
+ "name": "version_subtitle",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_cover_image_id": {
+ "name": "version_cover_image_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_published_date": {
+ "name": "version_published_date",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_body": {
+ "name": "version_body",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_updated_at": {
+ "name": "version_updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_created_at": {
+ "name": "version_created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version__status": {
+ "name": "version__status",
+ "type": "enum__post_v_version_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'draft'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "latest": {
+ "name": "latest",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "_post_v_parent_idx": {
+ "name": "_post_v_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_post_v_version_version_slug_idx": {
+ "name": "_post_v_version_version_slug_idx",
+ "columns": [
+ {
+ "expression": "version_slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_post_v_version_version_category_idx": {
+ "name": "_post_v_version_version_category_idx",
+ "columns": [
+ {
+ "expression": "version_category_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_post_v_version_version_cover_image_idx": {
+ "name": "_post_v_version_version_cover_image_idx",
+ "columns": [
+ {
+ "expression": "version_cover_image_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_post_v_version_version_updated_at_idx": {
+ "name": "_post_v_version_version_updated_at_idx",
+ "columns": [
+ {
+ "expression": "version_updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_post_v_version_version_created_at_idx": {
+ "name": "_post_v_version_version_created_at_idx",
+ "columns": [
+ {
+ "expression": "version_created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_post_v_version_version__status_idx": {
+ "name": "_post_v_version_version__status_idx",
+ "columns": [
+ {
+ "expression": "version__status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_post_v_created_at_idx": {
+ "name": "_post_v_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_post_v_updated_at_idx": {
+ "name": "_post_v_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_post_v_latest_idx": {
+ "name": "_post_v_latest_idx",
+ "columns": [
+ {
+ "expression": "latest",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "_post_v_parent_id_post_id_fk": {
+ "name": "_post_v_parent_id_post_id_fk",
+ "tableFrom": "_post_v",
+ "tableTo": "post",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "_post_v_version_category_id_tag_id_fk": {
+ "name": "_post_v_version_category_id_tag_id_fk",
+ "tableFrom": "_post_v",
+ "tableTo": "tag",
+ "columnsFrom": [
+ "version_category_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "_post_v_version_cover_image_id_media_id_fk": {
+ "name": "_post_v_version_cover_image_id_media_id_fk",
+ "tableFrom": "_post_v",
+ "tableTo": "media",
+ "columnsFrom": [
+ "version_cover_image_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public._post_v_rels": {
+ "name": "_post_v_rels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "path": {
+ "name": "path",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "_post_v_rels_order_idx": {
+ "name": "_post_v_rels_order_idx",
+ "columns": [
+ {
+ "expression": "order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_post_v_rels_parent_idx": {
+ "name": "_post_v_rels_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_post_v_rels_path_idx": {
+ "name": "_post_v_rels_path_idx",
+ "columns": [
+ {
+ "expression": "path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_post_v_rels_tag_id_idx": {
+ "name": "_post_v_rels_tag_id_idx",
+ "columns": [
+ {
+ "expression": "tag_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "_post_v_rels_parent_fk": {
+ "name": "_post_v_rels_parent_fk",
+ "tableFrom": "_post_v_rels",
+ "tableTo": "_post_v",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "_post_v_rels_tag_fk": {
+ "name": "_post_v_rels_tag_fk",
+ "tableFrom": "_post_v_rels",
+ "tableTo": "tag",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tag": {
+ "name": "tag",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hidden": {
+ "name": "hidden",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tag_slug_idx": {
+ "name": "tag_slug_idx",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tag_updated_at_idx": {
+ "name": "tag_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tag_created_at_idx": {
+ "name": "tag_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.payload_kv": {
+ "name": "payload_kv",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data": {
+ "name": "data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "payload_kv_key_idx": {
+ "name": "payload_kv_key_idx",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.payload_jobs_log": {
+ "name": "payload_jobs_log",
+ "schema": "",
+ "columns": {
+ "_order": {
+ "name": "_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "_parent_id": {
+ "name": "_parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "varchar",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "executed_at": {
+ "name": "executed_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "task_slug": {
+ "name": "task_slug",
+ "type": "enum_payload_jobs_log_task_slug",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "task_i_d": {
+ "name": "task_i_d",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "input": {
+ "name": "input",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "output": {
+ "name": "output",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "state": {
+ "name": "state",
+ "type": "enum_payload_jobs_log_state",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error": {
+ "name": "error",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "payload_jobs_log_order_idx": {
+ "name": "payload_jobs_log_order_idx",
+ "columns": [
+ {
+ "expression": "_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_jobs_log_parent_id_idx": {
+ "name": "payload_jobs_log_parent_id_idx",
+ "columns": [
+ {
+ "expression": "_parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "payload_jobs_log_parent_id_fk": {
+ "name": "payload_jobs_log_parent_id_fk",
+ "tableFrom": "payload_jobs_log",
+ "tableTo": "payload_jobs",
+ "columnsFrom": [
+ "_parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.payload_jobs": {
+ "name": "payload_jobs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "input": {
+ "name": "input",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_tried": {
+ "name": "total_tried",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "has_error": {
+ "name": "has_error",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "error": {
+ "name": "error",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "task_slug": {
+ "name": "task_slug",
+ "type": "enum_payload_jobs_task_slug",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "queue": {
+ "name": "queue",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'default'"
+ },
+ "wait_until": {
+ "name": "wait_until",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "processing": {
+ "name": "processing",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "payload_jobs_completed_at_idx": {
+ "name": "payload_jobs_completed_at_idx",
+ "columns": [
+ {
+ "expression": "completed_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_jobs_total_tried_idx": {
+ "name": "payload_jobs_total_tried_idx",
+ "columns": [
+ {
+ "expression": "total_tried",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_jobs_has_error_idx": {
+ "name": "payload_jobs_has_error_idx",
+ "columns": [
+ {
+ "expression": "has_error",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_jobs_task_slug_idx": {
+ "name": "payload_jobs_task_slug_idx",
+ "columns": [
+ {
+ "expression": "task_slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_jobs_queue_idx": {
+ "name": "payload_jobs_queue_idx",
+ "columns": [
+ {
+ "expression": "queue",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_jobs_wait_until_idx": {
+ "name": "payload_jobs_wait_until_idx",
+ "columns": [
+ {
+ "expression": "wait_until",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_jobs_processing_idx": {
+ "name": "payload_jobs_processing_idx",
+ "columns": [
+ {
+ "expression": "processing",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_jobs_updated_at_idx": {
+ "name": "payload_jobs_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_jobs_created_at_idx": {
+ "name": "payload_jobs_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.payload_locked_documents": {
+ "name": "payload_locked_documents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "global_slug": {
+ "name": "global_slug",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "payload_locked_documents_global_slug_idx": {
+ "name": "payload_locked_documents_global_slug_idx",
+ "columns": [
+ {
+ "expression": "global_slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_updated_at_idx": {
+ "name": "payload_locked_documents_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_created_at_idx": {
+ "name": "payload_locked_documents_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.payload_locked_documents_rels": {
+ "name": "payload_locked_documents_rels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "path": {
+ "name": "path",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "users_id": {
+ "name": "users_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "campaign_user_id": {
+ "name": "campaign_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "campaign_invite_id": {
+ "name": "campaign_invite_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "electoral_nucleus_id": {
+ "name": "electoral_nucleus_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "leadership_id": {
+ "name": "leadership_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "nucleus_update_id": {
+ "name": "nucleus_update_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action_plan_id": {
+ "name": "action_plan_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "election_tally_id": {
+ "name": "election_tally_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "election_candidate_vote_id": {
+ "name": "election_candidate_vote_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "election_candidate_id": {
+ "name": "election_candidate_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "media_id": {
+ "name": "media_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "petition_id": {
+ "name": "petition_id",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consent_id": {
+ "name": "consent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "signature_id": {
+ "name": "signature_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subscription_id": {
+ "name": "subscription_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "post_id": {
+ "name": "post_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "payload_locked_documents_rels_order_idx": {
+ "name": "payload_locked_documents_rels_order_idx",
+ "columns": [
+ {
+ "expression": "order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_parent_idx": {
+ "name": "payload_locked_documents_rels_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_path_idx": {
+ "name": "payload_locked_documents_rels_path_idx",
+ "columns": [
+ {
+ "expression": "path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_users_id_idx": {
+ "name": "payload_locked_documents_rels_users_id_idx",
+ "columns": [
+ {
+ "expression": "users_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_campaign_user_id_idx": {
+ "name": "payload_locked_documents_rels_campaign_user_id_idx",
+ "columns": [
+ {
+ "expression": "campaign_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_campaign_invite_id_idx": {
+ "name": "payload_locked_documents_rels_campaign_invite_id_idx",
+ "columns": [
+ {
+ "expression": "campaign_invite_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_electoral_nucleus_id_idx": {
+ "name": "payload_locked_documents_rels_electoral_nucleus_id_idx",
+ "columns": [
+ {
+ "expression": "electoral_nucleus_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_leadership_id_idx": {
+ "name": "payload_locked_documents_rels_leadership_id_idx",
+ "columns": [
+ {
+ "expression": "leadership_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_nucleus_update_id_idx": {
+ "name": "payload_locked_documents_rels_nucleus_update_id_idx",
+ "columns": [
+ {
+ "expression": "nucleus_update_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_action_plan_id_idx": {
+ "name": "payload_locked_documents_rels_action_plan_id_idx",
+ "columns": [
+ {
+ "expression": "action_plan_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_election_tally_id_idx": {
+ "name": "payload_locked_documents_rels_election_tally_id_idx",
+ "columns": [
+ {
+ "expression": "election_tally_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_election_candidate_vote_id_idx": {
+ "name": "payload_locked_documents_rels_election_candidate_vote_id_idx",
+ "columns": [
+ {
+ "expression": "election_candidate_vote_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_election_candidate_id_idx": {
+ "name": "payload_locked_documents_rels_election_candidate_id_idx",
+ "columns": [
+ {
+ "expression": "election_candidate_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_media_id_idx": {
+ "name": "payload_locked_documents_rels_media_id_idx",
+ "columns": [
+ {
+ "expression": "media_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_petition_id_idx": {
+ "name": "payload_locked_documents_rels_petition_id_idx",
+ "columns": [
+ {
+ "expression": "petition_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_contact_id_idx": {
+ "name": "payload_locked_documents_rels_contact_id_idx",
+ "columns": [
+ {
+ "expression": "contact_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_consent_id_idx": {
+ "name": "payload_locked_documents_rels_consent_id_idx",
+ "columns": [
+ {
+ "expression": "consent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_signature_id_idx": {
+ "name": "payload_locked_documents_rels_signature_id_idx",
+ "columns": [
+ {
+ "expression": "signature_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_subscription_id_idx": {
+ "name": "payload_locked_documents_rels_subscription_id_idx",
+ "columns": [
+ {
+ "expression": "subscription_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_post_id_idx": {
+ "name": "payload_locked_documents_rels_post_id_idx",
+ "columns": [
+ {
+ "expression": "post_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_locked_documents_rels_tag_id_idx": {
+ "name": "payload_locked_documents_rels_tag_id_idx",
+ "columns": [
+ {
+ "expression": "tag_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "payload_locked_documents_rels_parent_fk": {
+ "name": "payload_locked_documents_rels_parent_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "payload_locked_documents",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_users_fk": {
+ "name": "payload_locked_documents_rels_users_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "users",
+ "columnsFrom": [
+ "users_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_campaign_user_fk": {
+ "name": "payload_locked_documents_rels_campaign_user_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "campaign_user",
+ "columnsFrom": [
+ "campaign_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_campaign_invite_fk": {
+ "name": "payload_locked_documents_rels_campaign_invite_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "campaign_invite",
+ "columnsFrom": [
+ "campaign_invite_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_electoral_nucleus_fk": {
+ "name": "payload_locked_documents_rels_electoral_nucleus_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "electoral_nucleus",
+ "columnsFrom": [
+ "electoral_nucleus_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_leadership_fk": {
+ "name": "payload_locked_documents_rels_leadership_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "leadership",
+ "columnsFrom": [
+ "leadership_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_nucleus_update_fk": {
+ "name": "payload_locked_documents_rels_nucleus_update_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "nucleus_update",
+ "columnsFrom": [
+ "nucleus_update_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_action_plan_fk": {
+ "name": "payload_locked_documents_rels_action_plan_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "action_plan",
+ "columnsFrom": [
+ "action_plan_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_election_tally_fk": {
+ "name": "payload_locked_documents_rels_election_tally_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "election_tally",
+ "columnsFrom": [
+ "election_tally_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_election_candidate_vote_fk": {
+ "name": "payload_locked_documents_rels_election_candidate_vote_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "election_candidate_vote",
+ "columnsFrom": [
+ "election_candidate_vote_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_election_candidate_fk": {
+ "name": "payload_locked_documents_rels_election_candidate_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "election_candidate",
+ "columnsFrom": [
+ "election_candidate_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_media_fk": {
+ "name": "payload_locked_documents_rels_media_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_petition_fk": {
+ "name": "payload_locked_documents_rels_petition_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "petition",
+ "columnsFrom": [
+ "petition_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_contact_fk": {
+ "name": "payload_locked_documents_rels_contact_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "contact",
+ "columnsFrom": [
+ "contact_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_consent_fk": {
+ "name": "payload_locked_documents_rels_consent_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "consent",
+ "columnsFrom": [
+ "consent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_signature_fk": {
+ "name": "payload_locked_documents_rels_signature_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "signature",
+ "columnsFrom": [
+ "signature_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_subscription_fk": {
+ "name": "payload_locked_documents_rels_subscription_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "subscription",
+ "columnsFrom": [
+ "subscription_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_post_fk": {
+ "name": "payload_locked_documents_rels_post_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "post",
+ "columnsFrom": [
+ "post_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_locked_documents_rels_tag_fk": {
+ "name": "payload_locked_documents_rels_tag_fk",
+ "tableFrom": "payload_locked_documents_rels",
+ "tableTo": "tag",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.payload_preferences": {
+ "name": "payload_preferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "payload_preferences_key_idx": {
+ "name": "payload_preferences_key_idx",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_preferences_updated_at_idx": {
+ "name": "payload_preferences_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_preferences_created_at_idx": {
+ "name": "payload_preferences_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.payload_preferences_rels": {
+ "name": "payload_preferences_rels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "path": {
+ "name": "path",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "users_id": {
+ "name": "users_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "campaign_user_id": {
+ "name": "campaign_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "payload_preferences_rels_order_idx": {
+ "name": "payload_preferences_rels_order_idx",
+ "columns": [
+ {
+ "expression": "order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_preferences_rels_parent_idx": {
+ "name": "payload_preferences_rels_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_preferences_rels_path_idx": {
+ "name": "payload_preferences_rels_path_idx",
+ "columns": [
+ {
+ "expression": "path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_preferences_rels_users_id_idx": {
+ "name": "payload_preferences_rels_users_id_idx",
+ "columns": [
+ {
+ "expression": "users_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_preferences_rels_campaign_user_id_idx": {
+ "name": "payload_preferences_rels_campaign_user_id_idx",
+ "columns": [
+ {
+ "expression": "campaign_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "payload_preferences_rels_parent_fk": {
+ "name": "payload_preferences_rels_parent_fk",
+ "tableFrom": "payload_preferences_rels",
+ "tableTo": "payload_preferences",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_preferences_rels_users_fk": {
+ "name": "payload_preferences_rels_users_fk",
+ "tableFrom": "payload_preferences_rels",
+ "tableTo": "users",
+ "columnsFrom": [
+ "users_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "payload_preferences_rels_campaign_user_fk": {
+ "name": "payload_preferences_rels_campaign_user_fk",
+ "tableFrom": "payload_preferences_rels",
+ "tableTo": "campaign_user",
+ "columnsFrom": [
+ "campaign_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.payload_migrations": {
+ "name": "payload_migrations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "batch": {
+ "name": "batch",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "payload_migrations_updated_at_idx": {
+ "name": "payload_migrations_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payload_migrations_created_at_idx": {
+ "name": "payload_migrations_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.site_settings_social_links": {
+ "name": "site_settings_social_links",
+ "schema": "",
+ "columns": {
+ "_order": {
+ "name": "_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "_parent_id": {
+ "name": "_parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "varchar",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "platform": {
+ "name": "platform",
+ "type": "enum_site_settings_social_links_platform",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "site_settings_social_links_order_idx": {
+ "name": "site_settings_social_links_order_idx",
+ "columns": [
+ {
+ "expression": "_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "site_settings_social_links_parent_id_idx": {
+ "name": "site_settings_social_links_parent_id_idx",
+ "columns": [
+ {
+ "expression": "_parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "site_settings_social_links_parent_id_fk": {
+ "name": "site_settings_social_links_parent_id_fk",
+ "tableFrom": "site_settings_social_links",
+ "tableTo": "site_settings",
+ "columnsFrom": [
+ "_parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.site_settings": {
+ "name": "site_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "header_title": {
+ "name": "header_title",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "_status": {
+ "name": "_status",
+ "type": "enum_site_settings_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'draft'"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "site_settings__status_idx": {
+ "name": "site_settings__status_idx",
+ "columns": [
+ {
+ "expression": "_status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public._site_settings_v_version_social_links": {
+ "name": "_site_settings_v_version_social_links",
+ "schema": "",
+ "columns": {
+ "_order": {
+ "name": "_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "_parent_id": {
+ "name": "_parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "platform": {
+ "name": "platform",
+ "type": "enum__site_settings_v_version_social_links_platform",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "_uuid": {
+ "name": "_uuid",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "_site_settings_v_version_social_links_order_idx": {
+ "name": "_site_settings_v_version_social_links_order_idx",
+ "columns": [
+ {
+ "expression": "_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_site_settings_v_version_social_links_parent_id_idx": {
+ "name": "_site_settings_v_version_social_links_parent_id_idx",
+ "columns": [
+ {
+ "expression": "_parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "_site_settings_v_version_social_links_parent_id_fk": {
+ "name": "_site_settings_v_version_social_links_parent_id_fk",
+ "tableFrom": "_site_settings_v_version_social_links",
+ "tableTo": "_site_settings_v",
+ "columnsFrom": [
+ "_parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public._site_settings_v": {
+ "name": "_site_settings_v",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "version_header_title": {
+ "name": "version_header_title",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version__status": {
+ "name": "version__status",
+ "type": "enum__site_settings_v_version_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'draft'"
+ },
+ "version_updated_at": {
+ "name": "version_updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_created_at": {
+ "name": "version_created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "latest": {
+ "name": "latest",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "autosave": {
+ "name": "autosave",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "_site_settings_v_version_version__status_idx": {
+ "name": "_site_settings_v_version_version__status_idx",
+ "columns": [
+ {
+ "expression": "version__status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_site_settings_v_created_at_idx": {
+ "name": "_site_settings_v_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_site_settings_v_updated_at_idx": {
+ "name": "_site_settings_v_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_site_settings_v_latest_idx": {
+ "name": "_site_settings_v_latest_idx",
+ "columns": [
+ {
+ "expression": "latest",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "_site_settings_v_autosave_idx": {
+ "name": "_site_settings_v_autosave_idx",
+ "columns": [
+ {
+ "expression": "autosave",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.home": {
+ "name": "home",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "image_id": {
+ "name": "image_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "home_image_idx": {
+ "name": "home_image_idx",
+ "columns": [
+ {
+ "expression": "image_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "home_image_id_media_id_fk": {
+ "name": "home_image_id_media_id_fk",
+ "tableFrom": "home",
+ "tableTo": "media",
+ "columnsFrom": [
+ "image_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.metadata_keywords": {
+ "name": "metadata_keywords",
+ "schema": "",
+ "columns": {
+ "_order": {
+ "name": "_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "_parent_id": {
+ "name": "_parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "varchar",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "metadata_keywords_order_idx": {
+ "name": "metadata_keywords_order_idx",
+ "columns": [
+ {
+ "expression": "_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "metadata_keywords_parent_id_idx": {
+ "name": "metadata_keywords_parent_id_idx",
+ "columns": [
+ {
+ "expression": "_parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "metadata_keywords_parent_id_fk": {
+ "name": "metadata_keywords_parent_id_fk",
+ "tableFrom": "metadata_keywords",
+ "tableTo": "metadata",
+ "columnsFrom": [
+ "_parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.metadata": {
+ "name": "metadata",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "open_graph_site_name": {
+ "name": "open_graph_site_name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "open_graph_description": {
+ "name": "open_graph_description",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "twitter_creator": {
+ "name": "twitter_creator",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "twitter_description": {
+ "name": "twitter_description",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image_id": {
+ "name": "image_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp(3) with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "metadata_image_idx": {
+ "name": "metadata_image_idx",
+ "columns": [
+ {
+ "expression": "image_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "metadata_image_id_media_id_fk": {
+ "name": "metadata_image_id_media_id_fk",
+ "tableFrom": "metadata",
+ "tableTo": "media",
+ "columnsFrom": [
+ "image_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.enum_campaign_user_role": {
+ "name": "enum_campaign_user_role",
+ "schema": "public",
+ "values": [
+ "geral",
+ "coordenador",
+ "lideranca"
+ ]
+ },
+ "public.enum_campaign_invite_kind": {
+ "name": "enum_campaign_invite_kind",
+ "schema": "public",
+ "values": [
+ "login",
+ "autopreenchimento"
+ ]
+ },
+ "public.enum_electoral_nucleus_status": {
+ "name": "enum_electoral_nucleus_status",
+ "schema": "public",
+ "values": [
+ "ativo",
+ "arquivado"
+ ]
+ },
+ "public.enum_electoral_nucleus_organization_kind": {
+ "name": "enum_electoral_nucleus_organization_kind",
+ "schema": "public",
+ "values": [
+ "territorial",
+ "associacao",
+ "sindicato",
+ "religioso",
+ "movimento",
+ "categoria_profissional",
+ "outro"
+ ]
+ },
+ "public.enum_electoral_nucleus_sector_kind": {
+ "name": "enum_electoral_nucleus_sector_kind",
+ "schema": "public",
+ "values": [
+ "rural",
+ "religioso",
+ "sindical",
+ "empresarial",
+ "juventude",
+ "saude",
+ "educacao",
+ "cultura",
+ "outro"
+ ]
+ },
+ "public.enum_leadership_sector": {
+ "name": "enum_leadership_sector",
+ "schema": "public",
+ "values": [
+ "religioso",
+ "sindical",
+ "comunitario",
+ "rural",
+ "empresarial",
+ "juventude",
+ "saude",
+ "educacao",
+ "cultura",
+ "outro"
+ ]
+ },
+ "public.enum_leadership_support_status": {
+ "name": "enum_leadership_support_status",
+ "schema": "public",
+ "values": [
+ "engajado",
+ "a_abordar",
+ "em_disputa",
+ "negativo"
+ ]
+ },
+ "public.enum_nucleus_update_kind": {
+ "name": "enum_nucleus_update_kind",
+ "schema": "public",
+ "values": [
+ "semanal",
+ "urgente",
+ "nota"
+ ]
+ },
+ "public.enum_action_plan_kind": {
+ "name": "enum_action_plan_kind",
+ "schema": "public",
+ "values": [
+ "caminhada",
+ "comicio",
+ "carreata",
+ "panfletagem",
+ "porta_a_porta",
+ "reuniao_apoio",
+ "lancamento",
+ "convencao",
+ "ato",
+ "entrevista",
+ "producao_conteudo",
+ "digital",
+ "outro"
+ ]
+ },
+ "public.enum_action_plan_status": {
+ "name": "enum_action_plan_status",
+ "schema": "public",
+ "values": [
+ "rascunho",
+ "planejado",
+ "confirmado",
+ "realizado",
+ "cancelado"
+ ]
+ },
+ "public.enum_election_tally_office": {
+ "name": "enum_election_tally_office",
+ "schema": "public",
+ "values": [
+ "presidente",
+ "governador",
+ "deputado_federal",
+ "deputado_estadual"
+ ]
+ },
+ "public.enum_election_tally_turn": {
+ "name": "enum_election_tally_turn",
+ "schema": "public",
+ "values": [
+ "1",
+ "2"
+ ]
+ },
+ "public.enum_election_candidate_vote_office": {
+ "name": "enum_election_candidate_vote_office",
+ "schema": "public",
+ "values": [
+ "presidente",
+ "governador",
+ "deputado_federal",
+ "deputado_estadual"
+ ]
+ },
+ "public.enum_election_candidate_vote_turn": {
+ "name": "enum_election_candidate_vote_turn",
+ "schema": "public",
+ "values": [
+ "1",
+ "2"
+ ]
+ },
+ "public.enum_election_candidate_vote_vote_type": {
+ "name": "enum_election_candidate_vote_vote_type",
+ "schema": "public",
+ "values": [
+ "nominal",
+ "legenda"
+ ]
+ },
+ "public.enum_election_candidate_office": {
+ "name": "enum_election_candidate_office",
+ "schema": "public",
+ "values": [
+ "presidente",
+ "governador",
+ "deputado_federal",
+ "deputado_estadual"
+ ]
+ },
+ "public.enum_election_candidate_turn": {
+ "name": "enum_election_candidate_turn",
+ "schema": "public",
+ "values": [
+ "1",
+ "2"
+ ]
+ },
+ "public.enum_election_candidate_elected_by": {
+ "name": "enum_election_candidate_elected_by",
+ "schema": "public",
+ "values": [
+ "QP",
+ "média",
+ "2º turno"
+ ]
+ },
+ "public.enum_election_candidate_running_again2026": {
+ "name": "enum_election_candidate_running_again2026",
+ "schema": "public",
+ "values": [
+ "sim",
+ "nao",
+ "desconhecido"
+ ]
+ },
+ "public.enum_petition_status": {
+ "name": "enum_petition_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "published"
+ ]
+ },
+ "public.enum__petition_v_version_status": {
+ "name": "enum__petition_v_version_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "published"
+ ]
+ },
+ "public.enum_contact_gender": {
+ "name": "enum_contact_gender",
+ "schema": "public",
+ "values": [
+ "feminino",
+ "masculino",
+ "outro",
+ "nao_informado"
+ ]
+ },
+ "public.enum_contact_state": {
+ "name": "enum_contact_state",
+ "schema": "public",
+ "values": [
+ "AC",
+ "AL",
+ "AM",
+ "AP",
+ "BA",
+ "CE",
+ "DF",
+ "ES",
+ "GO",
+ "MA",
+ "MG",
+ "MS",
+ "MT",
+ "PA",
+ "PB",
+ "PE",
+ "PI",
+ "PR",
+ "RJ",
+ "RN",
+ "RO",
+ "RR",
+ "RS",
+ "SC",
+ "SE",
+ "SP",
+ "TO"
+ ]
+ },
+ "public.enum_post_type": {
+ "name": "enum_post_type",
+ "schema": "public",
+ "values": [
+ "noticia",
+ "campanha",
+ "artigo",
+ "evento"
+ ]
+ },
+ "public.enum_post_status": {
+ "name": "enum_post_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "published"
+ ]
+ },
+ "public.enum__post_v_version_type": {
+ "name": "enum__post_v_version_type",
+ "schema": "public",
+ "values": [
+ "noticia",
+ "campanha",
+ "artigo",
+ "evento"
+ ]
+ },
+ "public.enum__post_v_version_status": {
+ "name": "enum__post_v_version_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "published"
+ ]
+ },
+ "public.enum_payload_jobs_log_task_slug": {
+ "name": "enum_payload_jobs_log_task_slug",
+ "schema": "public",
+ "values": [
+ "inline",
+ "schedulePublish"
+ ]
+ },
+ "public.enum_payload_jobs_log_state": {
+ "name": "enum_payload_jobs_log_state",
+ "schema": "public",
+ "values": [
+ "failed",
+ "succeeded"
+ ]
+ },
+ "public.enum_payload_jobs_task_slug": {
+ "name": "enum_payload_jobs_task_slug",
+ "schema": "public",
+ "values": [
+ "inline",
+ "schedulePublish"
+ ]
+ },
+ "public.enum_site_settings_social_links_platform": {
+ "name": "enum_site_settings_social_links_platform",
+ "schema": "public",
+ "values": [
+ "instagram",
+ "facebook",
+ "youtube",
+ "whatsapp"
+ ]
+ },
+ "public.enum_site_settings_status": {
+ "name": "enum_site_settings_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "published"
+ ]
+ },
+ "public.enum__site_settings_v_version_social_links_platform": {
+ "name": "enum__site_settings_v_version_social_links_platform",
+ "schema": "public",
+ "values": [
+ "instagram",
+ "facebook",
+ "youtube",
+ "whatsapp"
+ ]
+ },
+ "public.enum__site_settings_v_version_status": {
+ "name": "enum__site_settings_v_version_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "published"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "id": "6197dff6-1567-4d92-a14a-02fc05d9bc61",
+ "prevId": "00000000-0000-0000-0000-000000000000"
+}
\ No newline at end of file
diff --git a/src/migrations/20260718_222832_add_action_plan.ts b/src/migrations/20260718_222832_add_action_plan.ts
new file mode 100644
index 00000000..e1eb8686
--- /dev/null
+++ b/src/migrations/20260718_222832_add_action_plan.ts
@@ -0,0 +1,119 @@
+import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
+
+export async function up({ db }: MigrateUpArgs): Promise {
+ await db.execute(sql`
+ CREATE TYPE "public"."enum_action_plan_kind" AS ENUM('caminhada', 'comicio', 'carreata', 'panfletagem', 'porta_a_porta', 'reuniao_apoio', 'lancamento', 'convencao', 'ato', 'entrevista', 'producao_conteudo', 'digital', 'outro');
+ CREATE TYPE "public"."enum_action_plan_status" AS ENUM('rascunho', 'planejado', 'confirmado', 'realizado', 'cancelado');
+
+ CREATE TABLE "action_plan_tasks" (
+ "_order" integer NOT NULL,
+ "_parent_id" integer NOT NULL,
+ "id" varchar PRIMARY KEY NOT NULL,
+ "title" varchar NOT NULL,
+ "responsible_id" integer,
+ "due" timestamp(3) with time zone,
+ "done" boolean DEFAULT false,
+ "done_at" timestamp(3) with time zone
+ );
+
+ CREATE TABLE "action_plan_updates" (
+ "_order" integer NOT NULL,
+ "_parent_id" integer NOT NULL,
+ "id" varchar PRIMARY KEY NOT NULL,
+ "body" varchar NOT NULL,
+ "author_id" integer,
+ "created_at" timestamp(3) with time zone
+ );
+
+ CREATE TABLE "action_plan" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "title" varchar NOT NULL,
+ "slug" varchar NOT NULL,
+ "kind" "enum_action_plan_kind" NOT NULL,
+ "status" "enum_action_plan_status" DEFAULT 'rascunho' NOT NULL,
+ "description" varchar,
+ "start_at" timestamp(3) with time zone,
+ "end_at" timestamp(3) with time zone,
+ "deadline" timestamp(3) with time zone,
+ "locality" varchar,
+ "territory_notes" varchar,
+ "responsible_id" integer,
+ "leadership_id" integer,
+ "created_by_id" integer,
+ "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
+ "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
+ );
+
+ CREATE TABLE "action_plan_texts" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "order" integer NOT NULL,
+ "parent_id" integer NOT NULL,
+ "path" varchar NOT NULL,
+ "text" varchar
+ );
+
+ CREATE TABLE "action_plan_rels" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "order" integer,
+ "parent_id" integer NOT NULL,
+ "path" varchar NOT NULL,
+ "campaign_user_id" integer
+ );
+
+ ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "action_plan_id" integer;
+
+ ALTER TABLE "action_plan_tasks" ADD CONSTRAINT "action_plan_tasks_responsible_id_contact_id_fk" FOREIGN KEY ("responsible_id") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE no action;
+ ALTER TABLE "action_plan_tasks" ADD CONSTRAINT "action_plan_tasks_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."action_plan"("id") ON DELETE cascade ON UPDATE no action;
+ ALTER TABLE "action_plan_updates" ADD CONSTRAINT "action_plan_updates_author_id_campaign_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
+ ALTER TABLE "action_plan_updates" ADD CONSTRAINT "action_plan_updates_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."action_plan"("id") ON DELETE cascade ON UPDATE no action;
+ ALTER TABLE "action_plan" ADD CONSTRAINT "action_plan_responsible_id_contact_id_fk" FOREIGN KEY ("responsible_id") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE no action;
+ ALTER TABLE "action_plan" ADD CONSTRAINT "action_plan_leadership_id_leadership_id_fk" FOREIGN KEY ("leadership_id") REFERENCES "public"."leadership"("id") ON DELETE set null ON UPDATE no action;
+ ALTER TABLE "action_plan" ADD CONSTRAINT "action_plan_created_by_id_campaign_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
+ ALTER TABLE "action_plan_texts" ADD CONSTRAINT "action_plan_texts_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."action_plan"("id") ON DELETE cascade ON UPDATE no action;
+ ALTER TABLE "action_plan_rels" ADD CONSTRAINT "action_plan_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."action_plan"("id") ON DELETE cascade ON UPDATE no action;
+ ALTER TABLE "action_plan_rels" ADD CONSTRAINT "action_plan_rels_campaign_user_fk" FOREIGN KEY ("campaign_user_id") REFERENCES "public"."campaign_user"("id") ON DELETE cascade ON UPDATE no action;
+
+ CREATE INDEX "action_plan_tasks_order_idx" ON "action_plan_tasks" USING btree ("_order");
+ CREATE INDEX "action_plan_tasks_parent_id_idx" ON "action_plan_tasks" USING btree ("_parent_id");
+ CREATE INDEX "action_plan_tasks_responsible_idx" ON "action_plan_tasks" USING btree ("responsible_id");
+ CREATE INDEX "action_plan_updates_order_idx" ON "action_plan_updates" USING btree ("_order");
+ CREATE INDEX "action_plan_updates_parent_id_idx" ON "action_plan_updates" USING btree ("_parent_id");
+ CREATE INDEX "action_plan_updates_author_idx" ON "action_plan_updates" USING btree ("author_id");
+ CREATE INDEX "action_plan_title_idx" ON "action_plan" USING btree ("title");
+ CREATE UNIQUE INDEX "action_plan_slug_idx" ON "action_plan" USING btree ("slug");
+ CREATE INDEX "action_plan_kind_idx" ON "action_plan" USING btree ("kind");
+ CREATE INDEX "action_plan_status_idx" ON "action_plan" USING btree ("status");
+ CREATE INDEX "action_plan_start_at_idx" ON "action_plan" USING btree ("start_at");
+ CREATE INDEX "action_plan_responsible_idx" ON "action_plan" USING btree ("responsible_id");
+ CREATE INDEX "action_plan_leadership_idx" ON "action_plan" USING btree ("leadership_id");
+ CREATE INDEX "action_plan_created_by_idx" ON "action_plan" USING btree ("created_by_id");
+ CREATE INDEX "action_plan_updated_at_idx" ON "action_plan" USING btree ("updated_at");
+ CREATE INDEX "action_plan_created_at_idx" ON "action_plan" USING btree ("created_at");
+ CREATE INDEX "action_plan_texts_order_parent" ON "action_plan_texts" USING btree ("order","parent_id");
+ CREATE INDEX "action_plan_texts_text_idx" ON "action_plan_texts" USING btree ("text");
+ CREATE INDEX "action_plan_rels_order_idx" ON "action_plan_rels" USING btree ("order");
+ CREATE INDEX "action_plan_rels_parent_idx" ON "action_plan_rels" USING btree ("parent_id");
+ CREATE INDEX "action_plan_rels_path_idx" ON "action_plan_rels" USING btree ("path");
+ CREATE INDEX "action_plan_rels_campaign_user_id_idx" ON "action_plan_rels" USING btree ("campaign_user_id");
+
+ ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_action_plan_fk" FOREIGN KEY ("action_plan_id") REFERENCES "public"."action_plan"("id") ON DELETE cascade ON UPDATE no action;
+ CREATE INDEX "payload_locked_documents_rels_action_plan_id_idx" ON "payload_locked_documents_rels" USING btree ("action_plan_id");
+ `)
+}
+
+export async function down({ db }: MigrateDownArgs): Promise {
+ await db.execute(sql`
+ ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_action_plan_fk";
+ DROP INDEX IF EXISTS "payload_locked_documents_rels_action_plan_id_idx";
+ ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "action_plan_id";
+
+ DROP TABLE IF EXISTS "action_plan_tasks" CASCADE;
+ DROP TABLE IF EXISTS "action_plan_updates" CASCADE;
+ DROP TABLE IF EXISTS "action_plan_texts" CASCADE;
+ DROP TABLE IF EXISTS "action_plan_rels" CASCADE;
+ DROP TABLE IF EXISTS "action_plan" CASCADE;
+
+ DROP TYPE IF EXISTS "public"."enum_action_plan_kind";
+ DROP TYPE IF EXISTS "public"."enum_action_plan_status";
+ `)
+}
diff --git a/src/migrations/index.ts b/src/migrations/index.ts
index 21e24ff2..332db1fa 100644
--- a/src/migrations/index.ts
+++ b/src/migrations/index.ts
@@ -7,6 +7,7 @@ import * as migration_20260718_010733_consolidate_campaign_schema from './202607
import * as migration_20260718_190559_territorio_multi_municipio_bairro from './20260718_190559_territorio_multi_municipio_bairro';
import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results';
import * as migration_20260718_222656_add_supporter from './20260718_222656_add_supporter';
+import * as migration_20260718_222832_add_action_plan from './20260718_222832_add_action_plan';
export const migrations = [
{
@@ -52,6 +53,11 @@ export const migrations = [
{
up: migration_20260718_222656_add_supporter.up,
down: migration_20260718_222656_add_supporter.down,
- name: '20260718_222656_add_supporter'
+ name: '20260718_222656_add_supporter',
+ },
+ {
+ up: migration_20260718_222832_add_action_plan.up,
+ down: migration_20260718_222832_add_action_plan.down,
+ name: '20260718_222832_add_action_plan',
},
];
diff --git a/src/payload-types.ts b/src/payload-types.ts
index fa2f8479..241e7673 100644
--- a/src/payload-types.ts
+++ b/src/payload-types.ts
@@ -75,6 +75,7 @@ export interface Config {
leadership: Leadership;
supporter: Supporter;
nucleusUpdate: NucleusUpdate;
+ actionPlan: ActionPlan;
electionTally: ElectionTally;
electionCandidateVote: ElectionCandidateVote;
electionCandidate: ElectionCandidate;
@@ -101,6 +102,7 @@ export interface Config {
leadership: LeadershipSelect | LeadershipSelect;
supporter: SupporterSelect | SupporterSelect;
nucleusUpdate: NucleusUpdateSelect | NucleusUpdateSelect;
+ actionPlan: ActionPlanSelect | ActionPlanSelect;
electionTally: ElectionTallySelect | ElectionTallySelect;
electionCandidateVote: ElectionCandidateVoteSelect | ElectionCandidateVoteSelect;
electionCandidate: ElectionCandidateSelect | ElectionCandidateSelect;
@@ -491,6 +493,63 @@ export interface NucleusUpdate {
updatedAt: string;
createdAt: string;
}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "actionPlan".
+ */
+export interface ActionPlan {
+ id: number;
+ title: string;
+ slug: string;
+ kind:
+ | 'caminhada'
+ | 'comicio'
+ | 'carreata'
+ | 'panfletagem'
+ | 'porta_a_porta'
+ | 'reuniao_apoio'
+ | 'lancamento'
+ | 'convencao'
+ | 'ato'
+ | 'entrevista'
+ | 'producao_conteudo'
+ | 'digital'
+ | 'outro';
+ status: 'rascunho' | 'planejado' | 'confirmado' | 'realizado' | 'cancelado';
+ description?: string | null;
+ startAt?: string | null;
+ endAt?: string | null;
+ deadline?: string | null;
+ regions?: string[] | null;
+ cities?: string[] | null;
+ neighborhoods?: string[] | null;
+ locality?: string | null;
+ territoryNotes?: string | null;
+ coordinators?: (number | CampaignUser)[] | null;
+ responsible?: (number | null) | Contact;
+ leadership?: (number | null) | Leadership;
+ tasks?:
+ | {
+ title: string;
+ responsible?: (number | null) | Contact;
+ due?: string | null;
+ done?: boolean | null;
+ doneAt?: string | null;
+ id?: string | null;
+ }[]
+ | null;
+ updates?:
+ | {
+ body: string;
+ author?: (number | null) | CampaignUser;
+ createdAt?: string | null;
+ id?: string | null;
+ }[]
+ | null;
+ createdBy?: (number | null) | CampaignUser;
+ updatedAt: string;
+ createdAt: string;
+}
/**
* Totais oficiais TSE por município e zona (dado público).
*
@@ -854,6 +913,10 @@ export interface PayloadLockedDocument {
relationTo: 'nucleusUpdate';
value: number | NucleusUpdate;
} | null)
+ | ({
+ relationTo: 'actionPlan';
+ value: number | ActionPlan;
+ } | null)
| ({
relationTo: 'electionTally';
value: number | ElectionTally;
@@ -1140,6 +1203,49 @@ export interface NucleusUpdateSelect {
updatedAt?: T;
createdAt?: T;
}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "actionPlan_select".
+ */
+export interface ActionPlanSelect {
+ title?: T;
+ slug?: T;
+ kind?: T;
+ status?: T;
+ description?: T;
+ startAt?: T;
+ endAt?: T;
+ deadline?: T;
+ regions?: T;
+ cities?: T;
+ neighborhoods?: T;
+ locality?: T;
+ territoryNotes?: T;
+ coordinators?: T;
+ responsible?: T;
+ leadership?: T;
+ tasks?:
+ | T
+ | {
+ title?: T;
+ responsible?: T;
+ due?: T;
+ done?: T;
+ doneAt?: T;
+ id?: T;
+ };
+ updates?:
+ | T
+ | {
+ body?: T;
+ author?: T;
+ createdAt?: T;
+ id?: T;
+ };
+ createdBy?: T;
+ updatedAt?: T;
+ createdAt?: T;
+}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "electionTally_select".
diff --git a/src/payload.config.ts b/src/payload.config.ts
index cd0f9487..202cdcec 100644
--- a/src/payload.config.ts
+++ b/src/payload.config.ts
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'url'
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
import { pt } from 'payload/i18n/pt'
+import { ActionPlan } from './collections/ActionPlan'
import { CampaignInvite } from './collections/CampaignInvite'
import { CampaignUser } from './collections/CampaignUser'
import { Consent } from './collections/Consent'
@@ -51,6 +52,7 @@ export default buildConfig({
Leadership,
Supporter,
NucleusUpdate,
+ ActionPlan,
ElectionTally,
ElectionCandidateVote,
ElectionCandidate,
diff --git a/src/utilities/actionPlanDetailTabUi.ts b/src/utilities/actionPlanDetailTabUi.ts
new file mode 100644
index 00000000..c141c198
--- /dev/null
+++ b/src/utilities/actionPlanDetailTabUi.ts
@@ -0,0 +1,60 @@
+export const actionPlanDetailTabs = ['overview', 'tasks', 'updates'] as const
+
+export type ActionPlanDetailTab = (typeof actionPlanDetailTabs)[number]
+export type ActionPlanDetailSearchParams = Record
+
+const tabQueryKeys: Record = {
+ overview: [],
+ tasks: [],
+ updates: ['newUpdate'],
+}
+
+const firstValue = (value: string | string[] | undefined): string | undefined =>
+ Array.isArray(value) ? value[0] : value
+
+const appendQueryValue = (
+ params: URLSearchParams,
+ key: string,
+ value: string | string[] | undefined,
+) => {
+ for (const item of Array.isArray(value) ? value : [value]) {
+ if (item !== undefined) params.append(key, item)
+ }
+}
+
+const isAllowedTab = (value: string | undefined): value is ActionPlanDetailTab =>
+ actionPlanDetailTabs.includes(value as ActionPlanDetailTab)
+
+export const resolveActionPlanDetailTab = (
+ searchParams: ActionPlanDetailSearchParams,
+): ActionPlanDetailTab => {
+ if (firstValue(searchParams.newUpdate) === '1') return 'updates'
+
+ const requestedTab = firstValue(searchParams.tab)
+ return isAllowedTab(requestedTab) ? requestedTab : 'overview'
+}
+
+export const buildActionPlanDetailTabHref = (
+ planSlug: string,
+ tab: ActionPlanDetailTab,
+ searchParams: ActionPlanDetailSearchParams,
+): string => {
+ const params = new URLSearchParams()
+ for (const key of tabQueryKeys[tab]) {
+ appendQueryValue(params, key, searchParams[key])
+ }
+ params.set('tab', tab)
+
+ return `/campanha/planos/${planSlug}?${params.toString()}`
+}
+
+export const getActionPlanDetailTabRedirect = (
+ planSlug: string,
+ searchParams: ActionPlanDetailSearchParams,
+): string | null => {
+ const activeTab = resolveActionPlanDetailTab(searchParams)
+ const requestedTab = searchParams.tab
+ if (typeof requestedTab === 'string' && requestedTab === activeTab) return null
+
+ return buildActionPlanDetailTabHref(planSlug, activeTab, searchParams)
+}
diff --git a/src/utilities/actionPlanFormData.ts b/src/utilities/actionPlanFormData.ts
new file mode 100644
index 00000000..2854b2bb
--- /dev/null
+++ b/src/utilities/actionPlanFormData.ts
@@ -0,0 +1,193 @@
+import 'server-only'
+
+import {
+ bahiaIdentityTerritories,
+ type BahiaIdentityTerritory,
+ isBahiaMunicipality,
+ territoriesForCities,
+} from '@/lib/bahiaTerritories'
+import {
+ actionPlanKinds,
+ actionPlanStatuses,
+ type ActionPlanCreateInput,
+ type ActionPlanUpdateInput,
+} from '@/lib/schemas/actionPlan'
+import {
+ boundedJsonFormValue,
+ FormDataBoundaryError,
+ nullableRelationshipFormValue,
+ optionalFormText,
+ repeatedFormTexts,
+ repeatedRelationshipFormValues,
+ requiredRelationshipFormValue,
+} from '@/lib/formData'
+import { parseBahiaDateTimeInput } from '@/utilities/campaignTime'
+
+type ParsedActionPlanTask = {
+ title: string
+ responsible?: number
+ due?: string
+ done?: boolean
+}
+
+const parseDateTimeFormField = (formData: FormData, field: string): string | undefined => {
+ const value = optionalFormText(formData, field)
+ if (!value) return undefined
+ const iso = parseBahiaDateTimeInput(value)
+ if (!iso) throw new FormDataBoundaryError(field, 'Data e horário inválidos.')
+ return iso
+}
+
+const parseTasksFormData = (formData: FormData): ParsedActionPlanTask[] => {
+ const raw = boundedJsonFormValue(formData, 'tasksJson', 20_000)
+ if (raw === undefined) return []
+ if (!Array.isArray(raw)) {
+ throw new FormDataBoundaryError('tasksJson', 'Lista de tarefas inválida.')
+ }
+
+ return raw.map((item, index) => {
+ if (typeof item !== 'object' || item === null) {
+ throw new FormDataBoundaryError('tasksJson', `Tarefa ${index + 1} inválida.`)
+ }
+ const record = item as Record
+ const title = typeof record.title === 'string' ? record.title.trim() : ''
+ if (!title) {
+ 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.`,
+ )
+ }
+ const responsible =
+ typeof record.responsible === 'number' &&
+ Number.isInteger(record.responsible) &&
+ record.responsible > 0
+ ? record.responsible
+ : undefined
+ const dueRaw = typeof record.due === 'string' ? record.due : undefined
+ const due = dueRaw ? (parseBahiaDateTimeInput(dueRaw) ?? undefined) : undefined
+ if (dueRaw && !due) {
+ throw new FormDataBoundaryError('tasksJson', `Prazo inválido na tarefa ${index + 1}.`)
+ }
+ const done = typeof record.done === 'boolean' ? record.done : undefined
+
+ return {
+ title,
+ ...(responsible ? { responsible } : {}),
+ ...(due ? { due } : {}),
+ ...(done !== undefined ? { done } : {}),
+ }
+ })
+}
+
+const parseSharedActionPlanFormData = (formData: FormData) => {
+ const cities = repeatedFormTexts(formData, 'cities')
+ const regions =
+ cities.length > 0
+ ? territoriesForCities(cities)
+ : (repeatedFormTexts(formData, 'regions') as BahiaIdentityTerritory[])
+ const neighborhoods = cities.length === 1 ? repeatedFormTexts(formData, 'neighborhoods') : []
+ const responsible = nullableRelationshipFormValue(formData, 'responsible')
+ const leadership = nullableRelationshipFormValue(formData, 'leadership')
+
+ return {
+ title: optionalFormText(formData, 'title') ?? '',
+ kind: optionalFormText(formData, 'kind') as (typeof actionPlanKinds)[number],
+ status: optionalFormText(formData, 'status') as (typeof actionPlanStatuses)[number] | undefined,
+ description: optionalFormText(formData, 'description'),
+ startAt: parseDateTimeFormField(formData, 'startAt'),
+ endAt: parseDateTimeFormField(formData, 'endAt'),
+ deadline: parseDateTimeFormField(formData, 'deadline'),
+ regions,
+ cities,
+ neighborhoods,
+ locality: optionalFormText(formData, 'locality'),
+ territoryNotes: optionalFormText(formData, 'territoryNotes'),
+ responsible,
+ leadership,
+ tasks: parseTasksFormData(formData),
+ }
+}
+
+const validateTerritoryFormData = (
+ territory: {
+ regions: string[]
+ cities: string[]
+ neighborhoods: string[]
+ locality?: string
+ },
+ { requireGeography }: { requireGeography: boolean },
+) => {
+ for (const region of territory.regions) {
+ if (!bahiaIdentityTerritories.includes(region as never)) {
+ throw new FormDataBoundaryError('regions', 'Território de identidade inválido.')
+ }
+ }
+ for (const city of territory.cities) {
+ if (!isBahiaMunicipality(city)) {
+ throw new FormDataBoundaryError('cities', 'Município inválido.')
+ }
+ }
+ if (territory.neighborhoods.length > 0 && territory.cities.length !== 1) {
+ throw new FormDataBoundaryError(
+ 'neighborhoods',
+ territory.cities.length === 0
+ ? 'Informe o município antes do bairro.'
+ : 'Bairros só podem ser informados quando há exatamente um município.',
+ )
+ }
+ if (
+ requireGeography &&
+ territory.regions.length === 0 &&
+ territory.cities.length === 0 &&
+ !territory.locality
+ ) {
+ throw new FormDataBoundaryError(
+ 'cities',
+ 'Informe o território de identidade, município ou localidade do plano.',
+ )
+ }
+}
+
+export const parseActionPlanCreateFormData = (formData: FormData): ActionPlanCreateInput => {
+ const shared = parseSharedActionPlanFormData(formData)
+ validateTerritoryFormData(shared, { requireGeography: false })
+ const coordinatorIds = repeatedRelationshipFormValues(formData, 'coordinators')
+
+ return {
+ ...shared,
+ status: (shared.status ?? 'rascunho') as ActionPlanCreateInput['status'],
+ responsible: shared.responsible ?? undefined,
+ leadership: shared.leadership ?? undefined,
+ coordinators: coordinatorIds.length ? coordinatorIds : undefined,
+ }
+}
+
+export const parseActionPlanUpdateFormData = (formData: FormData): ActionPlanUpdateInput => {
+ const shared = parseSharedActionPlanFormData(formData)
+ validateTerritoryFormData(shared, { requireGeography: true })
+ const coordinatorIds = repeatedRelationshipFormValues(formData, 'coordinators')
+ // Checkboxes only submit when checked, so a hidden marker distinguishes
+ // "coordinators section rendered but all unchecked" from "not editable" (coordenador view).
+ const hasCoordinators = formData.has('coordinatorsSubmitted')
+
+ return {
+ ...shared,
+ id: requiredRelationshipFormValue(formData, 'id'),
+ status: shared.status as ActionPlanUpdateInput['status'],
+ regions: shared.regions,
+ cities: shared.cities,
+ neighborhoods: shared.neighborhoods,
+ locality: shared.locality ?? null,
+ territoryNotes: shared.territoryNotes ?? null,
+ description: shared.description ?? null,
+ startAt: shared.startAt ?? null,
+ endAt: shared.endAt ?? null,
+ deadline: shared.deadline ?? null,
+ responsible: shared.responsible,
+ leadership: shared.leadership,
+ coordinators: hasCoordinators ? coordinatorIds : undefined,
+ }
+}
diff --git a/src/utilities/actionPlanLeadershipOptions.ts b/src/utilities/actionPlanLeadershipOptions.ts
new file mode 100644
index 00000000..40d3f86b
--- /dev/null
+++ b/src/utilities/actionPlanLeadershipOptions.ts
@@ -0,0 +1,48 @@
+import 'server-only'
+
+import type { Payload } from 'payload'
+
+import type { CampaignUser, Contact, Leadership } from '@/payload-types'
+import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
+
+export type ActionPlanLeadershipOption = {
+ id: number
+ label: string
+}
+
+/**
+ * Engaged leaderships visible to the actor (for linking a plan to a leadership).
+ * Uses overrideAccess: false so Payload row-level access applies.
+ */
+export const getActionPlanLeadershipOptions = async (
+ payload: Pick,
+ user: CampaignUser,
+): Promise => {
+ const result = await payload.find({
+ collection: 'leadership',
+ where: { supportStatus: { equals: 'engajado' } },
+ depth: 1,
+ limit: 200,
+ sort: 'id',
+ select: {
+ contact: true,
+ supportStatus: true,
+ },
+ user,
+ overrideAccess: false,
+ })
+
+ return result.docs
+ .map((doc) => {
+ const leadership = doc as Leadership
+ const id = relationshipId(leadership.id)
+ if (!id) return null
+ const contact = leadership.contact
+ const label = isPopulatedRelationship(contact)
+ ? contact.name
+ : `Liderança #${id}`
+ return { id, label }
+ })
+ .filter((option): option is ActionPlanLeadershipOption => option !== null)
+ .sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
+}
diff --git a/src/utilities/actionPlanPageData.ts b/src/utilities/actionPlanPageData.ts
new file mode 100644
index 00000000..8a7d2247
--- /dev/null
+++ b/src/utilities/actionPlanPageData.ts
@@ -0,0 +1,105 @@
+import type { Payload } from 'payload'
+
+import type { ActionPlan, CampaignUser } from '@/payload-types'
+import {
+ actionPlanDetailSelect,
+ actionPlanFormSelect,
+ actionPlanListSelect,
+ toActionPlanDetailViewModel,
+ toActionPlanFormViewModel,
+} from '@/utilities/actionPlanViewModels'
+import {
+ actionPlanPageSize,
+ buildActionPlanListWhere,
+ parseActionPlanListParams,
+} from '@/utilities/actionPlanUi'
+
+type ActionPlanListSearchParams = Record
+
+export class ActionPlanNotFoundError extends Error {
+ override name = 'ActionPlanNotFoundError'
+
+ constructor() {
+ super('Plano de ação não encontrado.')
+ }
+}
+
+export type AccessibleActionPlanContext = {
+ id: number
+ slug: string
+ document: ActionPlan
+}
+
+export const loadActionPlanListPageData = async (
+ payload: Pick,
+ user: CampaignUser,
+ searchParams: Promise | ActionPlanListSearchParams,
+ now = new Date(),
+) => {
+ const state = parseActionPlanListParams(await searchParams)
+ const result = await payload.find({
+ collection: 'actionPlan',
+ depth: 1,
+ limit: actionPlanPageSize,
+ page: state.page,
+ sort: 'startAt',
+ where: buildActionPlanListWhere(state, now),
+ select: actionPlanListSelect,
+ user,
+ overrideAccess: false,
+ })
+
+ return { result, state }
+}
+
+const loadAccessibleActionPlanBySlug = async (
+ payload: Pick,
+ user: CampaignUser,
+ planSlug: string,
+ select: typeof actionPlanDetailSelect | typeof actionPlanFormSelect,
+): Promise => {
+ const result = await payload.find({
+ collection: 'actionPlan',
+ where: { slug: { equals: planSlug } },
+ depth: 1,
+ limit: 1,
+ pagination: false,
+ select,
+ user,
+ overrideAccess: false,
+ })
+ const plan = result.docs[0]
+ if (!plan) throw new ActionPlanNotFoundError()
+ return plan as ActionPlan
+}
+
+export const resolveAccessibleActionPlanContext = async (
+ payload: Pick,
+ user: CampaignUser,
+ planSlug: string,
+): Promise => {
+ const document = await loadAccessibleActionPlanBySlug(
+ payload,
+ user,
+ planSlug,
+ actionPlanDetailSelect,
+ )
+
+ return {
+ id: document.id,
+ slug: document.slug,
+ document,
+ }
+}
+
+export const getActionPlanDetailPageData = (context: AccessibleActionPlanContext) =>
+ toActionPlanDetailViewModel(context.document)
+
+export const getActionPlanEditPageData = async (
+ payload: Payload,
+ user: CampaignUser,
+ planSlug: string,
+) =>
+ toActionPlanFormViewModel(
+ await loadAccessibleActionPlanBySlug(payload, user, planSlug, actionPlanFormSelect),
+ )
diff --git a/src/utilities/actionPlanUi.ts b/src/utilities/actionPlanUi.ts
new file mode 100644
index 00000000..1c66afd5
--- /dev/null
+++ b/src/utilities/actionPlanUi.ts
@@ -0,0 +1,209 @@
+import type { Where } from 'payload'
+
+import {
+ bahiaIdentityTerritories,
+ bahiaMunicipalities,
+ type BahiaIdentityTerritory,
+ territoryForCity,
+} from '@/lib/bahiaTerritories'
+import { actionPlanKinds, actionPlanStatuses } from '@/lib/schemas/actionPlan'
+import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
+
+export const actionPlanPageSize = 25
+
+export const actionPlanTabs = ['proximos', 'todos', 'realizados', 'rascunhos'] as const
+export type ActionPlanTab = (typeof actionPlanTabs)[number]
+
+export type ActionPlanKind = (typeof actionPlanKinds)[number]
+export type ActionPlanStatus = (typeof actionPlanStatuses)[number]
+
+export const actionPlanTabLabels: Record = {
+ proximos: 'Próximos',
+ todos: 'Todos',
+ realizados: 'Realizados',
+ rascunhos: 'Rascunhos',
+}
+
+export type ActionPlanListState = {
+ page: number
+ tab: ActionPlanTab
+ kind?: ActionPlanKind
+ status?: ActionPlanStatus
+ region?: BahiaIdentityTerritory
+ city?: string
+}
+
+type RawSearchParams = Record
+
+export const actionPlanListParamNames = ['tab', 'kind', 'status', 'region', 'city', 'page'] as const
+
+const actionPlanListParamNameSet = new Set(actionPlanListParamNames)
+
+const firstValue = (value: string | string[] | undefined): string | undefined =>
+ Array.isArray(value) ? value[0] : value
+
+const normalizedText = (value: string | undefined): string | undefined => {
+ if (typeof value !== 'string') return undefined
+ const normalized = value.trim()
+ return normalized || undefined
+}
+
+const strictDecimalInteger = (value: string | undefined): number | undefined => {
+ if (!value || !/^[1-9]\d*$/.test(value)) return undefined
+ const number = Number(value)
+ return Number.isSafeInteger(number) ? number : undefined
+}
+
+const canonicalTerritoryBySearchValue = new Map(
+ bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]),
+)
+
+const canonicalMunicipalityBySearchValue = new Map(
+ bahiaMunicipalities.map((city) => [normalizeSearchPhrase(city), city]),
+)
+
+const canonicalOfficialValue = (
+ value: string | undefined,
+ canonicalValues: ReadonlyMap,
+): Value | undefined => {
+ const normalized = normalizedText(value)
+ return normalized ? canonicalValues.get(normalizeSearchPhrase(normalized)) : undefined
+}
+
+const isActionPlanTab = (value: string | undefined): value is ActionPlanTab =>
+ actionPlanTabs.includes(value as ActionPlanTab)
+
+const isActionPlanKind = (value: string | undefined): value is ActionPlanKind =>
+ actionPlanKinds.includes(value as ActionPlanKind)
+
+const isActionPlanStatus = (value: string | undefined): value is ActionPlanStatus =>
+ actionPlanStatuses.includes(value as ActionPlanStatus)
+
+export const parseActionPlanListParams = (params: RawSearchParams): ActionPlanListState => {
+ const rawPage = strictDecimalInteger(firstValue(params.page))
+ const rawTab = firstValue(params.tab)
+ const tab = isActionPlanTab(rawTab) ? rawTab : 'proximos'
+ const rawKind = firstValue(params.kind)
+ const kind = isActionPlanKind(rawKind) ? rawKind : undefined
+ const rawStatus = tab === 'todos' ? firstValue(params.status) : undefined
+ const status = isActionPlanStatus(rawStatus) ? rawStatus : undefined
+ const region = canonicalOfficialValue(firstValue(params.region), canonicalTerritoryBySearchValue)
+ const canonicalCity = canonicalOfficialValue(
+ firstValue(params.city),
+ canonicalMunicipalityBySearchValue,
+ )
+ const city =
+ canonicalCity && (!region || territoryForCity(canonicalCity) === region)
+ ? canonicalCity
+ : undefined
+
+ return {
+ page: rawPage ?? 1,
+ tab,
+ ...(kind ? { kind } : {}),
+ ...(status ? { status } : {}),
+ ...(region ? { region } : {}),
+ ...(city ? { city } : {}),
+ }
+}
+
+export const buildActionPlanListWhere = (state: ActionPlanListState, now: Date): Where => {
+ const filters: Where[] = []
+
+ if (state.kind) filters.push({ kind: { equals: state.kind } })
+ if (state.region) filters.push({ regions: { equals: state.region } })
+ if (state.city) filters.push({ cities: { equals: state.city } })
+
+ if (state.tab === 'proximos') {
+ filters.push({ status: { in: ['planejado', 'confirmado'] } })
+ filters.push({ startAt: { greater_than_equal: now.toISOString() } })
+ } else if (state.tab === 'realizados') {
+ filters.push({ status: { equals: 'realizado' } })
+ } else if (state.tab === 'rascunhos') {
+ filters.push({ status: { equals: 'rascunho' } })
+ } else if (state.status) {
+ filters.push({ status: { equals: state.status } })
+ }
+
+ return filters.length ? { and: filters } : {}
+}
+
+export const buildActionPlanListSearchParams = (
+ state: ActionPlanListState,
+ page = state.page,
+): URLSearchParams => {
+ const canonicalState = parseActionPlanListParams({
+ page: String(page),
+ tab: state.tab,
+ kind: state.kind,
+ status: state.status,
+ region: state.region,
+ city: state.city,
+ })
+ const params = new URLSearchParams()
+
+ if (canonicalState.tab !== 'proximos') params.set('tab', canonicalState.tab)
+ if (canonicalState.kind) params.set('kind', canonicalState.kind)
+ if (canonicalState.status) params.set('status', canonicalState.status)
+ if (canonicalState.region) params.set('region', canonicalState.region)
+ if (canonicalState.city) params.set('city', canonicalState.city)
+ if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
+
+ return params
+}
+
+export const buildActionPlanFiltersKey = (state: ActionPlanListState): string =>
+ buildActionPlanListSearchParams(state).toString()
+
+export const buildActionPlanListHref = (state: ActionPlanListState, page: number): string => {
+ const params = buildActionPlanListSearchParams(state, page)
+ const query = params.toString()
+ return query ? `/campanha/planos?${query}` : '/campanha/planos'
+}
+
+const inspectRawActionPlanListParams = (
+ params: RawSearchParams,
+): { hasUnsupportedParams: boolean; query: string } => {
+ const serialized = new URLSearchParams()
+ let hasUnsupportedParams = false
+
+ for (const [name, value] of Object.entries(params)) {
+ if (!actionPlanListParamNameSet.has(name)) {
+ hasUnsupportedParams = true
+ continue
+ }
+ if (value === undefined) continue
+ for (const item of Array.isArray(value) ? value : [value]) {
+ serialized.append(name, item)
+ }
+ }
+
+ return { hasUnsupportedParams, query: serialized.toString() }
+}
+
+export const resolveActionPlanListUrl = (
+ params: RawSearchParams,
+ totalPages?: number,
+): {
+ state: ActionPlanListState
+ href: string
+ redirectHref?: string
+} => {
+ const parsedState = parseActionPlanListParams(params)
+ const page =
+ totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages
+ ? totalPages
+ : parsedState.page
+ const state = page === parsedState.page ? parsedState : { ...parsedState, page }
+ const canonicalParams = buildActionPlanListSearchParams(state)
+ const canonicalQuery = canonicalParams.toString()
+ const href = canonicalQuery ? `/campanha/planos?${canonicalQuery}` : '/campanha/planos'
+ const raw = inspectRawActionPlanListParams(params)
+ const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery
+
+ return {
+ state,
+ href,
+ ...(needsRedirect ? { redirectHref: href } : {}),
+ }
+}
diff --git a/src/utilities/actionPlanUpcomingPreview.ts b/src/utilities/actionPlanUpcomingPreview.ts
new file mode 100644
index 00000000..442b411a
--- /dev/null
+++ b/src/utilities/actionPlanUpcomingPreview.ts
@@ -0,0 +1,70 @@
+import type { Payload, Where } from 'payload'
+
+import type { ActionPlan, CampaignUser } from '@/payload-types'
+
+export const actionPlanUpcomingPreviewLimit = 3
+
+const upcomingActionPlanSelect = {
+ title: true,
+ slug: true,
+ kind: true,
+ startAt: true,
+ cities: true,
+} as const
+
+export type ActionPlanUpcomingPreviewRecord = {
+ id: number
+ slug: string
+ title: string
+ kind: ActionPlan['kind']
+ startAt: string
+ city: string | null
+}
+
+export type ActionPlanUpcomingPreviewFilters = {
+ region?: string
+ city?: string
+}
+
+export const loadUpcomingActionPlansPreview = async (
+ payload: Pick,
+ user: CampaignUser,
+ now: Date,
+ options: {
+ limit?: number
+ filters?: ActionPlanUpcomingPreviewFilters
+ } = {},
+): Promise => {
+ const limit = options.limit ?? actionPlanUpcomingPreviewLimit
+ const filters: Where[] = [
+ { status: { in: ['planejado', 'confirmado'] } },
+ { startAt: { greater_than_equal: now.toISOString() } },
+ ]
+ if (options.filters?.region) {
+ filters.push({ regions: { equals: options.filters.region } })
+ }
+ if (options.filters?.city) {
+ filters.push({ cities: { equals: options.filters.city } })
+ }
+
+ const result = await payload.find({
+ collection: 'actionPlan',
+ where: { and: filters },
+ depth: 0,
+ limit,
+ page: 1,
+ sort: 'startAt',
+ select: upcomingActionPlanSelect,
+ user,
+ overrideAccess: false,
+ })
+
+ return result.docs.map((plan) => ({
+ id: plan.id,
+ slug: plan.slug,
+ title: plan.title,
+ kind: plan.kind,
+ startAt: plan.startAt as string,
+ city: Array.isArray(plan.cities) && plan.cities.length > 0 ? plan.cities[0] : null,
+ }))
+}
diff --git a/src/utilities/actionPlanViewModels.ts b/src/utilities/actionPlanViewModels.ts
new file mode 100644
index 00000000..d489be32
--- /dev/null
+++ b/src/utilities/actionPlanViewModels.ts
@@ -0,0 +1,287 @@
+import type { ActionPlan, CampaignUser, Contact } from '@/payload-types'
+import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
+
+const asStringArray = (value: string[] | null | undefined): string[] =>
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
+
+export const formatActionPlanTerritoryLabel = ({
+ neighborhoods = [],
+ locality,
+ cities = [],
+ regions = [],
+}: {
+ neighborhoods?: string[] | null
+ locality?: string | null
+ cities?: string[] | null
+ regions?: string[] | null
+}): string =>
+ [
+ (neighborhoods ?? []).join(', '),
+ locality,
+ (cities ?? []).join(', '),
+ (regions ?? []).join(', '),
+ ]
+ .filter(Boolean)
+ .join(' · ')
+
+export const actionPlanListSelect = {
+ title: true,
+ slug: true,
+ kind: true,
+ status: true,
+ startAt: true,
+ endAt: true,
+ regions: true,
+ cities: true,
+ neighborhoods: true,
+ locality: true,
+ responsible: true,
+ tasks: { done: true },
+} as const
+
+export type ActionPlanListViewModel = {
+ id: number
+ title: string
+ slug: string
+ kind: ActionPlan['kind']
+ status: ActionPlan['status']
+ startAt: string | null
+ endAt: string | null
+ territoryLabel: string
+ city: string | null
+ responsibleName: string | null
+ taskProgress: { done: number; total: number }
+}
+
+const relationshipName = (
+ relationship: number | Contact | CampaignUser | null | undefined,
+): string | null =>
+ isPopulatedRelationship(relationship) ? relationship.name : null
+
+export const toActionPlanListViewModel = (plan: ActionPlan): ActionPlanListViewModel => {
+ const cities = asStringArray(plan.cities)
+ const tasks = plan.tasks ?? []
+
+ return {
+ id: plan.id,
+ title: plan.title,
+ slug: plan.slug,
+ kind: plan.kind,
+ status: plan.status,
+ startAt: plan.startAt ?? null,
+ endAt: plan.endAt ?? null,
+ territoryLabel: formatActionPlanTerritoryLabel(plan),
+ city: cities[0] ?? null,
+ responsibleName: relationshipName(plan.responsible),
+ taskProgress: {
+ done: tasks.filter((task) => Boolean(task.done)).length,
+ total: tasks.length,
+ },
+ }
+}
+
+export const actionPlanFormSelect = {
+ title: true,
+ slug: true,
+ kind: true,
+ status: true,
+ description: true,
+ startAt: true,
+ endAt: true,
+ deadline: true,
+ regions: true,
+ cities: true,
+ neighborhoods: true,
+ locality: true,
+ territoryNotes: true,
+ coordinators: true,
+ responsible: true,
+ leadership: true,
+ tasks: true,
+} as const
+
+export type ActionPlanFormTaskViewModel = {
+ id: string | null
+ title: string
+ responsible: { id: number; name: string; phone: string } | null
+ due: string | null
+ done: boolean
+}
+
+export type ActionPlanFormViewModel = {
+ id: number
+ title: string
+ slug: string
+ kind: ActionPlan['kind']
+ status: ActionPlan['status']
+ description: string | null
+ startAt: string | null
+ endAt: string | null
+ deadline: string | null
+ regions: string[]
+ cities: string[]
+ neighborhoods: string[]
+ locality: string | null
+ territoryNotes: string | null
+ coordinators: Array<{ id: number; name: string }>
+ responsible: { id: number; name: string; phone: string } | null
+ leadership: { id: number; label: string } | null
+ tasks: ActionPlanFormTaskViewModel[]
+}
+
+export const toActionPlanFormViewModel = (plan: ActionPlan): ActionPlanFormViewModel => ({
+ id: plan.id,
+ title: plan.title,
+ slug: plan.slug,
+ kind: plan.kind,
+ status: plan.status,
+ description: plan.description ?? null,
+ startAt: plan.startAt ?? null,
+ endAt: plan.endAt ?? null,
+ deadline: plan.deadline ?? null,
+ regions: asStringArray(plan.regions),
+ cities: asStringArray(plan.cities),
+ neighborhoods: asStringArray(plan.neighborhoods),
+ locality: plan.locality ?? null,
+ territoryNotes: plan.territoryNotes ?? null,
+ coordinators:
+ plan.coordinators
+ ?.filter((coordinator): coordinator is CampaignUser =>
+ isPopulatedRelationship(coordinator),
+ )
+ .map(({ id, name }) => ({ id, name })) ?? [],
+ responsible: isPopulatedRelationship(plan.responsible)
+ ? { id: plan.responsible.id, name: plan.responsible.name, phone: plan.responsible.phone }
+ : null,
+ leadership: (() => {
+ const leadershipId = relationshipId(plan.leadership)
+ if (!leadershipId) return null
+ if (isPopulatedRelationship(plan.leadership)) {
+ const contact = plan.leadership.contact
+ const contactName = isPopulatedRelationship(contact)
+ ? contact.name
+ : `Liderança #${leadershipId}`
+ return { id: leadershipId, label: contactName }
+ }
+ return { id: leadershipId, label: `Liderança #${leadershipId}` }
+ })(),
+ tasks: (plan.tasks ?? []).map((task) => ({
+ id: task.id ?? null,
+ title: task.title,
+ responsible: isPopulatedRelationship(task.responsible)
+ ? { id: task.responsible.id, name: task.responsible.name, phone: task.responsible.phone }
+ : null,
+ due: task.due ?? null,
+ done: Boolean(task.done),
+ })),
+})
+
+export const actionPlanDetailSelect = {
+ title: true,
+ slug: true,
+ kind: true,
+ status: true,
+ description: true,
+ startAt: true,
+ endAt: true,
+ deadline: true,
+ regions: true,
+ cities: true,
+ neighborhoods: true,
+ locality: true,
+ territoryNotes: true,
+ coordinators: true,
+ responsible: true,
+ tasks: true,
+ updates: true,
+ createdBy: true,
+ updatedAt: true,
+ createdAt: true,
+} as const
+
+export type ActionPlanTaskViewModel = {
+ id: string | null
+ title: string
+ responsibleId: number | null
+ responsibleName: string | null
+ due: string | null
+ done: boolean
+ doneAt: string | null
+}
+
+export type ActionPlanUpdateViewModel = {
+ id: string | null
+ body: string
+ authorName: string | null
+ createdAt: string | null
+}
+
+export type ActionPlanDetailViewModel = {
+ id: number
+ title: string
+ slug: string
+ kind: ActionPlan['kind']
+ status: ActionPlan['status']
+ description: string | null
+ startAt: string | null
+ endAt: string | null
+ deadline: string | null
+ regions: string[]
+ cities: string[]
+ neighborhoods: string[]
+ locality: string | null
+ territoryNotes: string | null
+ territoryLabel: string
+ coordinators: Array<{ id: number; name: string }>
+ responsibleId: number | null
+ responsibleName: string | null
+ tasks: ActionPlanTaskViewModel[]
+ updates: ActionPlanUpdateViewModel[]
+ createdByName: string | null
+ updatedAt: string
+ createdAt: string
+}
+
+export const toActionPlanDetailViewModel = (plan: ActionPlan): ActionPlanDetailViewModel => ({
+ id: plan.id,
+ title: plan.title,
+ slug: plan.slug,
+ kind: plan.kind,
+ status: plan.status,
+ description: plan.description ?? null,
+ startAt: plan.startAt ?? null,
+ endAt: plan.endAt ?? null,
+ deadline: plan.deadline ?? null,
+ regions: asStringArray(plan.regions),
+ cities: asStringArray(plan.cities),
+ neighborhoods: asStringArray(plan.neighborhoods),
+ locality: plan.locality ?? null,
+ territoryNotes: plan.territoryNotes ?? null,
+ territoryLabel: formatActionPlanTerritoryLabel(plan),
+ coordinators:
+ plan.coordinators
+ ?.filter((coordinator): coordinator is CampaignUser =>
+ isPopulatedRelationship(coordinator),
+ )
+ .map(({ id, name }) => ({ id, name })) ?? [],
+ responsibleId: relationshipId(plan.responsible),
+ responsibleName: relationshipName(plan.responsible),
+ tasks: (plan.tasks ?? []).map((task) => ({
+ id: task.id ?? null,
+ title: task.title,
+ responsibleId: relationshipId(task.responsible),
+ responsibleName: relationshipName(task.responsible),
+ due: task.due ?? null,
+ done: Boolean(task.done),
+ doneAt: task.doneAt ?? null,
+ })),
+ updates: (plan.updates ?? []).map((update) => ({
+ id: update.id ?? null,
+ body: update.body,
+ authorName: relationshipName(update.author),
+ createdAt: update.createdAt ?? null,
+ })),
+ createdByName: relationshipName(plan.createdBy),
+ updatedAt: plan.updatedAt,
+ createdAt: plan.createdAt,
+})
diff --git a/src/utilities/campaignAccess.ts b/src/utilities/campaignAccess.ts
index 51e59aba..3c65f6c8 100644
--- a/src/utilities/campaignAccess.ts
+++ b/src/utilities/campaignAccess.ts
@@ -10,8 +10,12 @@ type ContactID = number
const ACCESSIBLE_NUCLEUS_IDS_CONTEXT_KEY = 'campaignAccessibleNucleusIds'
const ACCESSIBLE_CONTACT_IDS_CONTEXT_KEY = 'campaignAccessibleContactIds'
+const ACCESSIBLE_LEADERSHIP_IDS_CONTEXT_KEY = 'campaignAccessibleLeadershipIds'
const CAMPAIGN_USER_PHONE_ACCESS_CONTEXT_KEY = 'campaignUserPhoneAccess'
+type LeadershipID = number
+type AccessibleLeadershipIDs = LeadershipID[] | null
+
export const isPayloadAdmin = (user: CampaignActor): user is User => user?.collection === 'users'
const isCampaignUser = (user: CampaignActor): user is CampaignUser =>
@@ -337,6 +341,59 @@ export const canReadElectionData: Access = ({ req }) =>
/** Election reference data is mutated only by Payload admins (or CLI with overrideAccess). */
export const canMutateElectionData: Access = ({ req }) => isPayloadAdmin(req.user)
+const canStaffCreateActionPlan: FieldAccess = async ({ req }) => {
+ if (isPayloadAdmin(req.user)) return true
+
+ const currentUser = await getFreshCampaignUser(req)
+ return currentUser?.role === 'geral' || currentUser?.role === 'coordenador'
+}
+
+export const canCreateActionPlan: Access = canStaffCreateActionPlan
+
+export const canReadActionPlan: Access = async ({ req }): Promise => {
+ if (isPayloadAdmin(req.user)) return true
+
+ const currentUser = await getFreshCampaignUser(req)
+ if (!currentUser) return false
+ if (isCampaignGeneral(currentUser)) return true
+
+ if (currentUser.role === 'coordenador') {
+ return {
+ coordinators: {
+ contains: currentUser.id,
+ },
+ }
+ }
+
+ if (currentUser.role === 'lideranca') {
+ const leadershipIDs = await getAccessibleLeadershipIds(req, currentUser)
+ return {
+ leadership: {
+ in: leadershipIDs ?? [],
+ },
+ }
+ }
+
+ return false
+}
+
+/** Same row scope as read — liderança write limits are enforced in `beforeChange`. */
+export const canUpdateActionPlan: Access = canReadActionPlan
+
+export const canDeleteActionPlan: Access = ({ req }) => isPayloadAdmin(req.user)
+
+export const canSetActionPlanSystemField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
+
+export const canSetActionPlanStatus: FieldAccess = canStaffCreateActionPlan
+
+export const canCreateActionPlanCoordinators: FieldAccess = canStaffCreateActionPlan
+
+export const canManageActionPlanCoordinators: FieldAccess = async ({ req }) => {
+ if (isPayloadAdmin(req.user)) return true
+
+ return isCampaignGeneral(await getFreshCampaignUser(req))
+}
+
type DynamicFind = (args: {
collection: string
depth: number
@@ -570,3 +627,72 @@ export const getAccessibleContactIds = async (
return ids
}
+
+/**
+ * Returns null for unrestricted general coordination, otherwise the engaged
+ * leadership IDs currently linked to the authenticated campaign user.
+ */
+export const getAccessibleLeadershipIds = async (
+ req: PayloadRequest,
+ user: CampaignActor = req.user,
+): Promise => {
+ const currentUser =
+ isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user
+
+ if (!isCampaignUser(currentUser)) return []
+ if (isCampaignGeneral(currentUser)) return null
+
+ const context = req.context as Record
+ const cacheKey = `${ACCESSIBLE_LEADERSHIP_IDS_CONTEXT_KEY}:${currentUser.id}:${currentUser.role}`
+ const cached = context[cacheKey]
+
+ if (Array.isArray(cached)) {
+ return cached.filter((id): id is number => typeof id === 'number')
+ }
+
+ const collections = req.payload.collections as Record
+ const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
+ let ids: LeadershipID[] = []
+
+ if (currentUser.role === 'lideranca' && collections.leadership) {
+ const result = await find({
+ collection: 'leadership',
+ depth: 0,
+ limit: 0,
+ overrideAccess: true,
+ pagination: false,
+ req,
+ select: { id: true },
+ where: {
+ and: [{ user: { equals: currentUser.id } }, { supportStatus: { equals: 'engajado' } }],
+ },
+ })
+
+ ids = result.docs.map((doc) => relationshipId(doc.id)).filter((id): id is number => id !== null)
+ }
+
+ if (currentUser.role === 'coordenador' && collections.leadership) {
+ const nucleusIDs = await getAccessibleNucleusIds(req, currentUser)
+ const result = await find({
+ collection: 'leadership',
+ depth: 0,
+ limit: 0,
+ overrideAccess: true,
+ pagination: false,
+ req,
+ select: { id: true },
+ where: {
+ nucleus: {
+ in: nucleusIDs ?? [],
+ },
+ },
+ })
+
+ ids = result.docs.map((doc) => relationshipId(doc.id)).filter((id): id is number => id !== null)
+ }
+
+ const uniqueIDs = [...new Set(ids)]
+ context[cacheKey] = uniqueIDs
+
+ return uniqueIDs
+}
diff --git a/src/utilities/campaignDashboardPageData.ts b/src/utilities/campaignDashboardPageData.ts
index 880fc759..8b0cdbc6 100644
--- a/src/utilities/campaignDashboardPageData.ts
+++ b/src/utilities/campaignDashboardPageData.ts
@@ -2,6 +2,7 @@ import type { Payload } from 'payload'
import type { CampaignUser } from '@/payload-types'
import { isSupportStatus } from '@/lib/schemas/leadership'
+import { loadUpcomingActionPlansPreview } from '@/utilities/actionPlanUpcomingPreview'
import { getBahiaWeekRange } from '@/utilities/campaignTime'
import { loadCoordinatorSummaries } from '@/utilities/nucleusCoordinatorOptions'
import {
@@ -102,7 +103,7 @@ export const getCampaignDashboardPageData = async (
now = new Date(),
) => {
const week = getBahiaWeekRange(now)
- const [nucleusResult, leadershipResult, updateResult] = await Promise.all([
+ const [nucleusResult, leadershipResult, updateResult, upcomingActionPlans] = await Promise.all([
payload.find({
collection: 'electoralNucleus',
where: { status: { equals: 'ativo' } },
@@ -142,6 +143,7 @@ export const getCampaignDashboardPageData = async (
overrideAccess: false,
})
: Promise.resolve({ totalDocs: 0 }),
+ loadUpcomingActionPlansPreview(payload, user, now),
])
const rawNuclei = nucleusResult.docs as unknown as RawDashboardNucleus[]
@@ -184,8 +186,14 @@ export const getCampaignDashboardPageData = async (
})
if (user.role === 'geral') {
- return buildGeneralDashboardViewModel(nuclei, leaderships, updateResult.totalDocs, now)
+ return buildGeneralDashboardViewModel(
+ nuclei,
+ leaderships,
+ updateResult.totalDocs,
+ now,
+ upcomingActionPlans,
+ )
}
- return buildScopedDashboardViewModel(user.role, nuclei, leaderships, now)
+ return buildScopedDashboardViewModel(user.role, nuclei, leaderships, now, upcomingActionPlans)
}
diff --git a/src/utilities/campaignDashboardViewModels.ts b/src/utilities/campaignDashboardViewModels.ts
index ef8cd965..f79f7092 100644
--- a/src/utilities/campaignDashboardViewModels.ts
+++ b/src/utilities/campaignDashboardViewModels.ts
@@ -1,5 +1,6 @@
import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
import type { SupportStatus } from '@/lib/schemas/leadership'
+import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import { formatNucleusTerritoryLabel } from '@/utilities/nucleusUi'
export type CoordinatorSummary = {
@@ -56,6 +57,7 @@ export type GeneralDashboardViewModel = {
withoutRecentUpdate: DashboardQueueItem[]
pendingEstimate: DashboardQueueItem[]
}
+ upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}
export type CoordinatorDashboardCard = {
@@ -94,10 +96,12 @@ export type ScopedDashboardViewModel =
| {
role: 'coordenador'
cards: CoordinatorDashboardCard[]
+ upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}
| {
role: 'lideranca'
cards: LeadershipDashboardCard[]
+ upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}
const percentage = (part: number, total: number): number =>
@@ -168,6 +172,7 @@ export const buildGeneralDashboardViewModel = (
leaderships: DashboardLeadershipRecord[],
updatesThisWeek: number,
now: Date,
+ upcomingActionPlans: ActionPlanUpcomingPreviewRecord[],
): GeneralDashboardViewModel => {
const coordinatedCount = nuclei.filter(({ coordinators }) => coordinators.length > 0).length
const confirmedCount = nuclei.filter(
@@ -202,6 +207,7 @@ export const buildGeneralDashboardViewModel = (
)
.map(toQueueItem),
},
+ upcomingActionPlans,
}
}
@@ -210,6 +216,7 @@ export const buildScopedDashboardViewModel = (
nuclei: DashboardNucleusRecord[],
leaderships: DashboardLeadershipRecord[],
now: Date,
+ upcomingActionPlans: ActionPlanUpcomingPreviewRecord[],
): ScopedDashboardViewModel => {
if (role === 'lideranca') {
return {
@@ -224,6 +231,7 @@ export const buildScopedDashboardViewModel = (
confirmedVoteEstimate: nucleus.confirmedVoteEstimate,
coordinators: coordinatorContacts(nucleus.coordinators),
})),
+ upcomingActionPlans,
}
}
const { countsByNucleus } = aggregateLeaderships(leaderships)
@@ -247,5 +255,6 @@ export const buildScopedDashboardViewModel = (
disputed: 0,
},
})),
+ upcomingActionPlans,
}
}
diff --git a/src/utilities/campaignTerritoryValidation.ts b/src/utilities/campaignTerritoryValidation.ts
new file mode 100644
index 00000000..766c1b3a
--- /dev/null
+++ b/src/utilities/campaignTerritoryValidation.ts
@@ -0,0 +1,114 @@
+import type { CollectionBeforeValidateHook } from 'payload'
+import { APIError } from 'payload'
+
+import {
+ isBahiaIdentityTerritory,
+ isBahiaMunicipality,
+ territoriesForCities,
+} from '@/lib/bahiaTerritories'
+import {
+ MAX_NUCLEUS_CITIES,
+ MAX_NUCLEUS_NEIGHBORHOODS,
+ MAX_NUCLEUS_REGIONS,
+} from '@/lib/schemas/nucleus'
+
+const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
+
+export const normalizeTerritoryTextArray = (value: unknown): string[] => {
+ if (!Array.isArray(value)) return []
+ const seen = new Set()
+ const result: string[] = []
+ for (const item of value) {
+ const trimmed = typeof item === 'string' ? item.trim() : ''
+ if (!trimmed || seen.has(trimmed)) continue
+ seen.add(trimmed)
+ result.push(trimmed)
+ }
+ return result
+}
+
+const resolveTerritoryArray = (
+ data: Record | undefined,
+ originalDoc: Record | undefined,
+ field: 'regions' | 'cities' | 'neighborhoods',
+ operation: 'create' | 'update' | 'delete',
+): string[] => {
+ if (operation === 'update' && data && !(field in data)) {
+ return normalizeTerritoryTextArray(originalDoc?.[field])
+ }
+ return normalizeTerritoryTextArray(data?.[field])
+}
+
+export type CampaignTerritoryValidationOptions = {
+ /** Portuguese entity label used in the "require territory" error (e.g. "núcleo", "plano"). */
+ entityLabel: string
+}
+
+/**
+ * Shared Bahia territory validation for campaign collections that own
+ * regions/cities/neighborhoods/locality/territoryNotes (núcleo, plano de ação).
+ */
+export const createCampaignTerritoryValidationHook = (
+ options: CampaignTerritoryValidationOptions,
+): CollectionBeforeValidateHook => {
+ return ({ data, operation, originalDoc }) => {
+ if (!data) return data
+
+ const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
+ const cities = resolveTerritoryArray(data, originalDoc, 'cities', operation)
+ const neighborhoods = resolveTerritoryArray(data, originalDoc, 'neighborhoods', operation)
+ let regions = resolveTerritoryArray(data, originalDoc, 'regions', operation)
+ const locality = trimmedText(nextData?.locality)
+ const territoryNotes = trimmedText(nextData?.territoryNotes)
+
+ if (cities.length > MAX_NUCLEUS_CITIES) {
+ throw new APIError(`Informe no máximo ${MAX_NUCLEUS_CITIES} municípios.`, 400)
+ }
+ if (regions.length > MAX_NUCLEUS_REGIONS) {
+ throw new APIError(`Informe no máximo ${MAX_NUCLEUS_REGIONS} territórios.`, 400)
+ }
+ if (neighborhoods.length > MAX_NUCLEUS_NEIGHBORHOODS) {
+ throw new APIError(`Informe no máximo ${MAX_NUCLEUS_NEIGHBORHOODS} bairros.`, 400)
+ }
+
+ for (const city of cities) {
+ if (!isBahiaMunicipality(city)) {
+ throw new APIError('Selecione um município válido da Bahia.', 400)
+ }
+ }
+
+ if (cities.length > 0) {
+ regions = territoriesForCities(cities)
+ } else {
+ for (const region of regions) {
+ if (!isBahiaIdentityTerritory(region)) {
+ throw new APIError('Selecione um território de identidade válido da Bahia.', 400)
+ }
+ }
+ }
+
+ if (neighborhoods.length > 0 && cities.length !== 1) {
+ throw new APIError(
+ cities.length === 0
+ ? 'Informe o município antes do bairro.'
+ : 'Bairros só podem ser informados quando há exatamente um município.',
+ 400,
+ )
+ }
+
+ if (regions.length === 0 && cities.length === 0 && !locality) {
+ throw new APIError(
+ `Informe o território de identidade, município ou localidade do ${options.entityLabel}.`,
+ 400,
+ )
+ }
+
+ data.regions = regions
+ data.cities = cities
+ data.neighborhoods = cities.length === 1 ? neighborhoods : []
+ data.locality = locality || null
+ data.territoryNotes = territoryNotes || null
+
+ return data
+ }
+}
diff --git a/src/utilities/campaignTime.ts b/src/utilities/campaignTime.ts
index 5f0e3bc8..a472381f 100644
--- a/src/utilities/campaignTime.ts
+++ b/src/utilities/campaignTime.ts
@@ -73,6 +73,49 @@ const addCivilDays = (civil: CivilDateTime, days: number): CivilDateTime => {
}
}
+const bahiaDateTimeInputPattern = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
+
+/**
+ * Converts a `datetime-local` input value (interpreted as Bahia civil time,
+ * since campaign staff operate in that timezone) into a UTC ISO instant.
+ * Returns null when the value does not match the expected input shape.
+ */
+export const parseBahiaDateTimeInput = (value: string): string | null => {
+ const match = bahiaDateTimeInputPattern.exec(value)
+ if (!match) return null
+ const [, year, month, day, hour, minute] = match
+
+ return zonedCivilToInstant({
+ year: Number(year),
+ month: Number(month),
+ day: Number(day),
+ hour: Number(hour),
+ minute: Number(minute),
+ second: 0,
+ }).toISOString()
+}
+
+/** Formats a UTC ISO instant as a `datetime-local` input value in Bahia civil time. */
+export const formatIsoAsBahiaDateTimeInput = (iso: string): string => {
+ const { year, month, day, hour, minute } = getZonedParts(new Date(iso))
+ const pad = (value: number): string => String(value).padStart(2, '0')
+
+ return `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}`
+}
+
+const bahiaDateTimeDisplayFormatter = new Intl.DateTimeFormat('pt-BR', {
+ timeZone: BAHIA_TIME_ZONE,
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+})
+
+/** Formats a UTC ISO instant as `dd/mm/aaaa às hh:mm` in Bahia civil time. */
+export const formatBahiaDateTimeLabel = (iso: string): string =>
+ bahiaDateTimeDisplayFormatter.format(new Date(iso)).replace(', ', ' às ')
+
export const getBahiaWeekRange = (now: Date): { start: Date; end: Date } => {
const local = getZonedParts(now)
const localMidnight = { ...local, hour: 0, minute: 0, second: 0 }
diff --git a/src/utilities/nucleusListOverviewPageData.ts b/src/utilities/nucleusListOverviewPageData.ts
index 088a382b..f4e01be3 100644
--- a/src/utilities/nucleusListOverviewPageData.ts
+++ b/src/utilities/nucleusListOverviewPageData.ts
@@ -1,6 +1,7 @@
import type { Payload } from 'payload'
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
+import { loadUpcomingActionPlansPreview } from '@/utilities/actionPlanUpcomingPreview'
import {
buildNucleusListOverviewViewModel,
nucleusListOverviewPreviewLimit,
@@ -54,17 +55,26 @@ export const loadNucleusListOverviewData = async (
payload: Pick,
user: CampaignUser,
state: NucleusListState,
+ now = new Date(),
): Promise => {
- const nucleusResult = await payload.find({
- collection: 'electoralNucleus',
- where: buildNucleusListWhere(state),
- depth: 0,
- pagination: false,
- select:
- user.role === 'lideranca' ? overviewLeadershipNucleusSelect : overviewStaffNucleusSelect,
- user,
- overrideAccess: false,
- })
+ const upcomingFilters = {
+ ...(state.region ? { region: state.region } : {}),
+ ...(state.city ? { city: state.city } : {}),
+ }
+
+ const [nucleusResult, upcomingActionPlans] = await Promise.all([
+ payload.find({
+ collection: 'electoralNucleus',
+ where: buildNucleusListWhere(state),
+ depth: 0,
+ pagination: false,
+ select:
+ user.role === 'lideranca' ? overviewLeadershipNucleusSelect : overviewStaffNucleusSelect,
+ user,
+ overrideAccess: false,
+ }),
+ loadUpcomingActionPlansPreview(payload, user, now, { filters: upcomingFilters }),
+ ])
const rawNuclei = nucleusResult.docs as unknown as RawOverviewNucleus[]
if (rawNuclei.length === 0) return null
@@ -133,5 +143,6 @@ export const loadNucleusListOverviewData = async (
nuclei,
recentUpdates,
role: user.role,
+ upcomingActionPlans,
})
}
diff --git a/src/utilities/nucleusListOverviewViewModels.ts b/src/utilities/nucleusListOverviewViewModels.ts
index 3d6d792e..3a88b7ff 100644
--- a/src/utilities/nucleusListOverviewViewModels.ts
+++ b/src/utilities/nucleusListOverviewViewModels.ts
@@ -1,4 +1,5 @@
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
+import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
export const nucleusListOverviewPreviewLimit = 3
@@ -34,6 +35,7 @@ export type NucleusListOverviewViewModel = {
percent: number
}
recentUpdates: NucleusListOverviewUpdateRecord[]
+ upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}
const percentage = (part: number, total: number): number =>
@@ -43,10 +45,12 @@ export const buildNucleusListOverviewViewModel = ({
nuclei,
recentUpdates,
role,
+ upcomingActionPlans,
}: {
nuclei: NucleusListOverviewNucleusRecord[]
recentUpdates: NucleusListOverviewUpdateRecord[]
role: CampaignUser['role']
+ upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}): NucleusListOverviewViewModel => {
const totalFiltered = nuclei.length
const confirmedCount = nuclei.filter(
@@ -77,5 +81,6 @@ export const buildNucleusListOverviewViewModel = ({
percent: percentage(coordinatedCount, totalFiltered),
},
recentUpdates,
+ upcomingActionPlans,
}
}
diff --git a/tests/e2e/campaign-action-plans.e2e.spec.ts b/tests/e2e/campaign-action-plans.e2e.spec.ts
new file mode 100644
index 00000000..55795613
--- /dev/null
+++ b/tests/e2e/campaign-action-plans.e2e.spec.ts
@@ -0,0 +1,73 @@
+/* eslint-disable check-file/filename-naming-convention */
+import { test, expect } from './fixtures/campaignE2EFixtures'
+
+test.describe('Campaign action plan journeys', () => {
+ test.describe.configure({ mode: 'parallel' })
+
+ test('creates a planned action and opens it from the list', async ({ campaign, page }) => {
+ test.setTimeout(240_000)
+ const generalEmail = `${campaign.fixtures.value('plan-general')}@example.com`
+ const password = campaign.fixtures.value('PlanPassword')
+ const planTitle = campaign.fixtures.value('Caminhada E2E')
+
+ await campaign.transaction(async (payload, req) => {
+ await payload.create({
+ collection: 'campaignUser',
+ data: {
+ name: campaign.fixtures.value('Coordenação Geral Planos'),
+ email: generalEmail,
+ password,
+ role: 'geral',
+ },
+ depth: 0,
+ req,
+ })
+ })
+
+ await campaign.login(page, generalEmail, 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.getByRole('button', { name: 'Planejado', exact: true }).click()
+ await page.locator('#startAt').fill('2026-08-20T09:00')
+ await page.getByLabel('Municípios').fill('Salvador')
+ await page.getByRole('option', { name: 'Salvador', exact: true }).click()
+ await page.getByRole('button', { name: 'Criar plano' }).click()
+
+ await expect(page).toHaveURL(new RegExp(`/campanha/planos/.+`), { timeout: 30_000 })
+ await expect(page.getByRole('heading', { name: planTitle })).toBeVisible()
+
+ await page.goto(`${campaign.baseURL}/campanha/planos?tab=todos`)
+ await expect(page.getByRole('link', { name: planTitle })).toBeVisible()
+ })
+
+ test('hides create affordance for leadership role', async ({ campaign, page }) => {
+ test.slow()
+ const phone = campaign.fixtures.phone()
+ const password = campaign.fixtures.value('LeadershipPlanPassword')
+
+ await campaign.transaction(async (payload, req) => {
+ await payload.create({
+ collection: 'campaignUser',
+ data: {
+ name: campaign.fixtures.value('Liderança Planos'),
+ username: phone,
+ password,
+ role: 'lideranca',
+ phone,
+ },
+ depth: 0,
+ req,
+ })
+ })
+
+ await campaign.login(
+ page,
+ `+55 (${phone.slice(0, 2)}) ${phone.slice(2)}`,
+ password,
+ )
+ await page.goto(`${campaign.baseURL}/campanha/planos`)
+ await expect(page.getByRole('heading', { name: /Planos/i })).toBeVisible()
+ await expect(page.getByRole('link', { name: /Novo plano/i })).toHaveCount(0)
+ })
+})
diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts
index 3e25f33a..b4442da1 100644
--- a/tests/helpers/campaignFixtures.ts
+++ b/tests/helpers/campaignFixtures.ts
@@ -21,6 +21,7 @@ type CampaignCollection =
| 'users'
| 'campaignInvite'
| 'nucleusUpdate'
+ | 'actionPlan'
| 'leadership'
| 'supporter'
| 'electoralNucleus'
@@ -76,6 +77,7 @@ const emptyOwnedIDs = (): OwnedIDs => ({
users: new Set(),
campaignInvite: new Set(),
nucleusUpdate: new Set(),
+ actionPlan: new Set(),
leadership: new Set(),
supporter: new Set(),
electoralNucleus: new Set(),
@@ -271,6 +273,14 @@ export class CampaignFixtures {
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 } },
@@ -283,7 +293,8 @@ export class CampaignFixtures {
for (const user of roots[1].docs) this.own('campaignUser', user)
for (const contact of roots[2].docs) this.own('contact', contact)
for (const nucleus of roots[3].docs) this.own('electoralNucleus', nucleus)
- for (const consent of roots[4].docs) this.own('consent', consent)
+ for (const plan of roots[4].docs) this.own('actionPlan', plan)
+ for (const consent of roots[5].docs) this.own('consent', consent)
}
async createCampaignUser(
@@ -557,6 +568,7 @@ export class CampaignFixtures {
}
const lockedDocumentConditions = [
+ ['action_plan_id', this.owned.actionPlan],
['campaign_invite_id', this.owned.campaignInvite],
['campaign_user_id', this.owned.campaignUser],
['consent_id', this.owned.consent],
@@ -624,6 +636,7 @@ export class CampaignFixtures {
for (const collection of [
'campaignInvite',
'nucleusUpdate',
+ 'actionPlan',
'leadership',
'supporter',
'electoralNucleus',
diff --git a/tests/int/campaignActionPlan.int.spec.ts b/tests/int/campaignActionPlan.int.spec.ts
new file mode 100644
index 00000000..ebc028de
--- /dev/null
+++ b/tests/int/campaignActionPlan.int.spec.ts
@@ -0,0 +1,242 @@
+// @vitest-environment node
+
+import { beforeAll, describe, expect, it } from 'vitest'
+import { getPayload, type Payload } from 'payload'
+
+import {
+ actionPlanCreateSchema,
+ actionPlanUpdateSchema,
+} from '@/lib/schemas/actionPlan'
+import config from '@/payload.config'
+import {
+ canCreateActionPlan,
+ canReadActionPlan,
+ canUpdateActionPlan,
+ getAccessibleLeadershipIds,
+} from '@/utilities/campaignAccess'
+
+import { installCampaignFixtures } from '../helpers/campaignFixtures'
+
+let payload: Payload
+const campaignFixtures = installCampaignFixtures({
+ getPayload: () => payload,
+ setPayload: (nextPayload) => {
+ payload = nextPayload
+ },
+})
+
+const validPlanInput = {
+ get title() {
+ return campaignFixtures().value('Caminhada Centro')
+ },
+ kind: 'caminhada' as const,
+ status: 'planejado' as const,
+ startAt: new Date(Date.now() + 86_400_000).toISOString(),
+ regions: ['Metropolitano de Salvador'],
+ cities: ['Salvador'],
+ locality: 'Centro',
+}
+
+describe('action plan domain', () => {
+ beforeAll(async () => {
+ payload = await getPayload({ config: await config })
+ })
+
+ it('requires startAt outside rascunho and allows drafts without it', () => {
+ expect(
+ actionPlanCreateSchema.safeParse({
+ ...validPlanInput,
+ status: 'rascunho',
+ startAt: undefined,
+ }).success,
+ ).toBe(true)
+
+ expect(
+ actionPlanCreateSchema.safeParse({
+ ...validPlanInput,
+ status: 'planejado',
+ startAt: undefined,
+ }).success,
+ ).toBe(false)
+
+ expect(
+ actionPlanUpdateSchema.safeParse({
+ id: 1,
+ status: 'confirmado',
+ startAt: null,
+ }).success,
+ ).toBe(false)
+ })
+
+ it('requires territory geography on create', () => {
+ const result = actionPlanCreateSchema.safeParse({
+ title: validPlanInput.title,
+ kind: 'caminhada',
+ status: 'rascunho',
+ })
+ expect(result.success).toBe(false)
+ })
+
+ it('scopes create/read/update by campaign role', async () => {
+ const fixtures = campaignFixtures()
+ const geral = await fixtures.createCampaignUser('geral')
+ const coordinator = await fixtures.createCampaignUser('coordenador')
+ const otherCoordinator = await fixtures.createCampaignUser('coordenador')
+ const leadershipUser = await fixtures.createCampaignUser('lideranca')
+ const contact = await fixtures.createContact()
+ const nucleus = await fixtures.createNucleus({
+ coordinators: [coordinator.id],
+ })
+ const leadership = await fixtures.createLeadership({
+ contact: contact.id,
+ nucleus: nucleus.id,
+ user: leadershipUser.id,
+ supportStatus: 'engajado',
+ })
+
+ const createAsGeral = await canCreateActionPlan({
+ req: { user: geral, payload, context: {} } as never,
+ })
+ const createAsCoordinator = await canCreateActionPlan({
+ req: { user: coordinator, payload, context: {} } as never,
+ })
+ const createAsLeadership = await canCreateActionPlan({
+ req: { user: leadershipUser, payload, context: {} } as never,
+ })
+ expect(createAsGeral).toBe(true)
+ expect(createAsCoordinator).toBe(true)
+ expect(createAsLeadership).toBe(false)
+
+ const plan = await payload.create({
+ collection: 'actionPlan',
+ data: {
+ ...validPlanInput,
+ coordinators: [coordinator.id],
+ leadership: leadership.id,
+ responsible: contact.id,
+ createdBy: geral.id,
+ } as never,
+ overrideAccess: true,
+ })
+ fixtures.own('actionPlan', plan.id)
+
+ const geralRead = await canReadActionPlan({
+ req: { user: geral, payload, context: {} } as never,
+ })
+ expect(geralRead).toBe(true)
+
+ const coordinatorRead = await canReadActionPlan({
+ req: { user: coordinator, payload, context: {} } as never,
+ })
+ expect(coordinatorRead).toEqual({
+ coordinators: { contains: coordinator.id },
+ })
+
+ const otherCoordinatorRead = await canReadActionPlan({
+ req: { user: otherCoordinator, payload, context: {} } as never,
+ })
+ expect(otherCoordinatorRead).toEqual({
+ coordinators: { contains: otherCoordinator.id },
+ })
+
+ const leadershipRead = await canReadActionPlan({
+ req: { user: leadershipUser, payload, context: {} } as never,
+ })
+ expect(leadershipRead).toEqual({
+ leadership: { in: [leadership.id] },
+ })
+
+ const leadershipIds = await getAccessibleLeadershipIds(
+ { user: leadershipUser, payload, context: {} } as never,
+ leadershipUser,
+ )
+ expect(leadershipIds).toEqual([leadership.id])
+
+ const coordinatorUpdate = await canUpdateActionPlan({
+ req: { user: coordinator, payload, context: {} } as never,
+ })
+ expect(coordinatorUpdate).toEqual({
+ coordinators: { contains: coordinator.id },
+ })
+
+ const visibleToCoordinator = await payload.find({
+ collection: 'actionPlan',
+ where: { id: { equals: plan.id } },
+ user: coordinator,
+ overrideAccess: false,
+ depth: 0,
+ })
+ expect(visibleToCoordinator.totalDocs).toBe(1)
+
+ const hiddenFromOther = await payload.find({
+ collection: 'actionPlan',
+ where: { id: { equals: plan.id } },
+ user: otherCoordinator,
+ overrideAccess: false,
+ depth: 0,
+ })
+ expect(hiddenFromOther.totalDocs).toBe(0)
+
+ const visibleToLeadership = await payload.find({
+ collection: 'actionPlan',
+ where: { id: { equals: plan.id } },
+ user: leadershipUser,
+ overrideAccess: false,
+ depth: 0,
+ })
+ expect(visibleToLeadership.totalDocs).toBe(1)
+ })
+
+ it('lets leadership toggle task done but rejects title edits', async () => {
+ const fixtures = campaignFixtures()
+ const coordinator = await fixtures.createCampaignUser('coordenador')
+ const leadershipUser = await fixtures.createCampaignUser('lideranca')
+ const contact = await fixtures.createContact()
+ const nucleus = await fixtures.createNucleus({
+ coordinators: [coordinator.id],
+ })
+ const leadership = await fixtures.createLeadership({
+ contact: contact.id,
+ nucleus: nucleus.id,
+ user: leadershipUser.id,
+ supportStatus: 'engajado',
+ })
+
+ const plan = await payload.create({
+ collection: 'actionPlan',
+ data: {
+ ...validPlanInput,
+ title: fixtures.value('Plano Tarefas'),
+ coordinators: [coordinator.id],
+ leadership: leadership.id,
+ tasks: [{ title: 'Levar faixas', done: false }],
+ } as never,
+ overrideAccess: true,
+ })
+ fixtures.own('actionPlan', plan.id)
+
+ const toggled = await payload.update({
+ collection: 'actionPlan',
+ id: plan.id,
+ data: {
+ tasks: [{ title: 'Levar faixas', done: true }],
+ } as never,
+ user: leadershipUser,
+ overrideAccess: false,
+ })
+ expect(toggled.tasks?.[0]?.done).toBe(true)
+ expect(toggled.tasks?.[0]?.doneAt).toBeTruthy()
+
+ await expect(
+ payload.update({
+ collection: 'actionPlan',
+ id: plan.id,
+ data: {
+ title: 'Título alterado pela liderança',
+ } as never,
+ user: leadershipUser,
+ overrideAccess: false,
+ }),
+ ).rejects.toThrow(/só podem marcar tarefas|não pode ser alterado|Lideranças/i)
+ })
+})
diff --git a/tests/int/campaignNucleusListOverview.int.spec.ts b/tests/int/campaignNucleusListOverview.int.spec.ts
index d3fe167d..d14d577a 100644
--- a/tests/int/campaignNucleusListOverview.int.spec.ts
+++ b/tests/int/campaignNucleusListOverview.int.spec.ts
@@ -37,21 +37,21 @@ describe('campaign nucleus list overview page data', () => {
const filteredA = await fixtures.createNucleus({
name: fixtures.value('Overview A'),
- region: 'Velho Chico',
- city: 'Bom Jesus da Lapa',
+ regions: ['Velho Chico'],
+ cities: ['Bom Jesus da Lapa'],
coordinators: [author.id],
organizationKind: 'territorial',
})
const filteredB = await fixtures.createNucleus({
name: fixtures.value('Overview B'),
- region: 'Velho Chico',
- city: 'Bom Jesus da Lapa',
+ regions: ['Velho Chico'],
+ cities: ['Bom Jesus da Lapa'],
organizationKind: 'territorial',
})
const outside = await fixtures.createNucleus({
name: fixtures.value('Overview fora'),
- region: 'Metropolitano de Salvador',
- city: 'Salvador',
+ regions: ['Metropolitano de Salvador'],
+ cities: ['Salvador'],
organizationKind: 'territorial',
})
@@ -131,15 +131,15 @@ describe('campaign nucleus list overview page data', () => {
const assigned = await fixtures.createNucleus({
name: fixtures.value('Atribuído overview'),
- region: 'Metropolitano de Salvador',
- city: 'Salvador',
+ regions: ['Metropolitano de Salvador'],
+ cities: ['Salvador'],
coordinators: [coordinator.id],
organizationKind: 'territorial',
})
const other = await fixtures.createNucleus({
name: fixtures.value('Não atribuído'),
- region: 'Metropolitano de Salvador',
- city: 'Salvador',
+ regions: ['Metropolitano de Salvador'],
+ cities: ['Salvador'],
coordinators: [otherCoordinator.id],
organizationKind: 'territorial',
})
@@ -184,8 +184,8 @@ describe('campaign nucleus list overview page data', () => {
const nucleus = await fixtures.createNucleus({
name: fixtures.value('Núcleo liderança'),
- region: 'Metropolitano de Salvador',
- city: 'Salvador',
+ regions: ['Metropolitano de Salvador'],
+ cities: ['Salvador'],
coordinators: [staffAuthor.id],
organizationKind: 'territorial',
})
diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts
index 4e4571ab..0a3bfbc6 100644
--- a/tests/unit/campaignComponents.unit.spec.ts
+++ b/tests/unit/campaignComponents.unit.spec.ts
@@ -109,6 +109,7 @@ describe('campaign visual foundation', () => {
],
0,
new Date('2026-07-18T12:00:00-03:00'),
+ [],
)
expect(view.supportCounts).toEqual({
diff --git a/tests/unit/campaignDashboardPageData.unit.spec.ts b/tests/unit/campaignDashboardPageData.unit.spec.ts
index 84919406..6d154818 100644
--- a/tests/unit/campaignDashboardPageData.unit.spec.ts
+++ b/tests/unit/campaignDashboardPageData.unit.spec.ts
@@ -26,6 +26,9 @@ const dashboardPayload = (leaderships: Array<{ nucleus: number; supportStatus?:
if (collection === 'nucleusUpdate') {
return { docs: [], totalDocs: 0 }
}
+ if (collection === 'actionPlan') {
+ return { docs: [], totalDocs: 0 }
+ }
throw new Error(`Unexpected collection: ${collection}`)
}),
}) as unknown as Payload
diff --git a/tests/unit/campaignNucleusListOverview.unit.spec.ts b/tests/unit/campaignNucleusListOverview.unit.spec.ts
index 658f5520..23220049 100644
--- a/tests/unit/campaignNucleusListOverview.unit.spec.ts
+++ b/tests/unit/campaignNucleusListOverview.unit.spec.ts
@@ -42,6 +42,7 @@ describe('buildNucleusListOverviewViewModel', () => {
nucleus({ id: 4 }),
],
recentUpdates: [],
+ upcomingActionPlans: [],
})
expect(view.totalFiltered).toBe(4)
@@ -66,6 +67,7 @@ describe('buildNucleusListOverviewViewModel', () => {
nucleus({ id: 2, proposedVoteEstimate: 400 }),
],
recentUpdates: [],
+ upcomingActionPlans: [],
})
expect(view.estimate).not.toHaveProperty('pendingSuggestionsCount')
@@ -92,6 +94,7 @@ describe('NucleusListOverview', () => {
createdAt: '2026-07-17T12:00:00.000Z',
},
],
+ upcomingActionPlans: [],
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
@@ -116,6 +119,7 @@ describe('NucleusListOverview', () => {
nucleus({ id: 2, proposedVoteEstimate: 400 }),
],
recentUpdates: [],
+ upcomingActionPlans: [],
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
diff --git a/tests/unit/campaignNucleusUi.unit.spec.ts b/tests/unit/campaignNucleusUi.unit.spec.ts
index abe0bd3f..1326a09e 100644
--- a/tests/unit/campaignNucleusUi.unit.spec.ts
+++ b/tests/unit/campaignNucleusUi.unit.spec.ts
@@ -27,23 +27,27 @@ import { matchesAtWordStart } from '@/utilities/wordStartFilter'
describe('campaign nucleus UI contracts', () => {
afterEach(cleanup)
- it('exposes home, nuclei, and supporters navigation scoped by role', () => {
+ it('exposes home, nuclei, action-plan, and supporters navigation scoped by role', () => {
expect(campaignNav.map(({ href, title }) => ({ href, title }))).toEqual([
{ href: '/campanha', title: 'Início' },
{ href: '/campanha/nucleos', title: 'Núcleos' },
+ { href: '/campanha/planos', title: 'Planos' },
{ href: '/campanha/apoiadores', title: 'Apoiadores' },
])
expect(getCampaignNav('geral').map(({ href }) => href)).toEqual([
'/campanha',
'/campanha/nucleos',
+ '/campanha/planos',
'/campanha/apoiadores',
])
expect(getCampaignNav('lideranca').map(({ href, title }) => ({ href, title }))).toEqual([
{ href: '/campanha', title: 'Início' },
{ href: '/campanha/nucleos', title: 'Meus núcleos' },
+ { href: '/campanha/planos', title: 'Planos' },
])
expect(isCampaignNavActive('/campanha/nucleos/nucleo-chapada', '/campanha/nucleos')).toBe(true)
expect(isCampaignNavActive('/campanha/nucleos', '/campanha')).toBe(false)
+ expect(isCampaignNavActive('/campanha/planos/novo', '/campanha/planos')).toBe(true)
expect(isCampaignNavActive('/campanha/apoiadores/1', '/campanha/apoiadores')).toBe(true)
})