From b035cc4a2f1ba0a264a73b8cc23b92276645ae87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sat, 18 Jul 2026 18:31:51 -0300 Subject: [PATCH] Support multi-municipality and multi-neighborhood nucleus territory. Replace scalar region/city/neighborhood with hasMany arrays so nuclei can span multiple Bahia municipalities while keeping neighborhoods scoped to a single city. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 3 +- .../territorio-multi-municipio-bairro.md | 151 +- docs/plans/zonas-por-municipio.md | 10 +- docs/roadmap.md | 2 +- .../campanha/(app)/nucleos/[slug]/page.tsx | 6 +- .../(campaign)/campanha/actions/leadership.ts | 3 +- src/collections/ElectoralNucleus.ts | 120 +- src/components/campaign/NucleusActiveTab.tsx | 9 +- src/components/campaign/NucleusList.tsx | 4 +- .../campaign/NucleusTerritoryFields.tsx | 318 +- src/lib/bahiaTerritories.ts | 9 + src/lib/formData.ts | 15 + src/lib/schemas/nucleus.ts | 173 +- ...559_territorio_multi_municipio_bairro.json | 6585 +++++++++++++++++ ...90559_territorio_multi_municipio_bairro.ts | 147 + src/migrations/index.ts | 7 +- src/payload-types.ts | 12 +- src/utilities/campaignDashboardPageData.ts | 27 +- src/utilities/campaignDashboardViewModels.ts | 11 +- src/utilities/nucleusFormData.ts | 59 +- src/utilities/nucleusPageData.ts | 6 +- src/utilities/nucleusUi.ts | 24 +- src/utilities/nucleusViewModels.ts | 87 +- tests/e2e/campaign-auth-rbac.e2e.spec.ts | 6 +- ...aign-composition-accessibility.e2e.spec.ts | 2 +- .../campaign-estimates-updates.e2e.spec.ts | 4 +- .../campaign-leadership-invite.e2e.spec.ts | 6 +- tests/e2e/campaign-nuclei.e2e.spec.ts | 8 +- tests/e2e/fixtures/campaignE2EFixtures.ts | 2 +- tests/helpers/campaignFixtures.ts | 2 +- tests/int/bahiaTerritories.int.spec.ts | 22 +- tests/int/campaignAuth.int.spec.ts | 2 +- tests/int/campaignInviteUi.int.spec.ts | 4 +- .../int/campaignLeadershipActions.int.spec.ts | 4 +- tests/int/campaignNucleus.int.spec.ts | 194 +- .../campaignNucleusDetailContext.int.spec.ts | 4 +- .../campaignNucleusUpdatePageData.int.spec.ts | 8 +- ...campaignPrimaryContactPageData.int.spec.ts | 4 +- .../unit/campaignNucleusFilters.unit.spec.ts | 4 +- ...campaignNucleusIntelligenceUi.unit.spec.ts | 24 +- .../campaignNucleusSummaryLayout.unit.spec.ts | 7 +- tests/unit/campaignNucleusUi.unit.spec.ts | 193 +- 42 files changed, 7748 insertions(+), 540 deletions(-) create mode 100644 src/migrations/20260718_190559_territorio_multi_municipio_bairro.json create mode 100644 src/migrations/20260718_190559_territorio_multi_municipio_bairro.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 7c7bf396..e14abcd1 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -77,7 +77,8 @@ alwaysApply: false - Advisory locks de runtime usam a sessão exata da transação Payload via `postgresTransactionLocks.ts`, com chaves textuais deduplicadas/ordenadas e namespaces independentes (`vote-estimate`, `nucleus-updates`, `primary-contact`, convites, telefone de contato e designação de coordenação). Leases de Consent permanecem exclusivamente em helpers de teste. - A fronteira pública de convites exporta somente as três Server Actions; helpers privilegiados de SQL, autorização, token, consentimento e recuperação ficam em módulos `server-only`, com limites arquiteturais cobertos por teste. - Em 2026-07-17, `Contact.city` passou a opcional porque Núcleos podem representar um Território de Identidade sem município/localidade; os fluxos públicos continuam exigindo cidade em seus próprios schemas. O cadastro de liderança mascara o celular no cliente, normaliza 11 dígitos no servidor e preserva valores/erros após falhas. -- Em 2026-07-17, decisão de produto: `Município` e `Bairro` do Núcleo passam a aceitar múltiplas entradas (arrays planos `cities`/`neighborhoods`), e `region` vira `regions` derivado no servidor a partir dos municípios, permitindo cruzar Territórios de Identidade. Bairros exigem município único; filtros da lista mudam de `equals` para `contains`. Modelo A (arrays planos) escolhido em vez de array estruturado por município. Plano: [`docs/plans/territorio-multi-municipio-bairro.md`](../../../docs/plans/territorio-multi-municipio-bairro.md); item do roadmap: "Território multi-município e multi-bairro". +- Em 2026-07-17, decisão de produto: `Município` e `Bairro` do Núcleo passam a aceitar múltiplas entradas (arrays planos `cities`/`neighborhoods`), e `region` vira `regions` derivado no servidor a partir dos municípios, permitindo cruzar Territórios de Identidade. Bairros exigem município único. Modelo A (arrays planos) escolhido em vez de array estruturado por município. Plano: [`docs/plans/territorio-multi-municipio-bairro.md`](../../../docs/plans/territorio-multi-municipio-bairro.md); item do roadmap: A1. +- Em 2026-07-18, **A1 entregue:** `regions`/`cities`/`neighborhoods` como `text` + `hasMany` (tabela `electoral_nucleus_texts`); migration `20260718_190559_territorio_multi_municipio_bairro` com backfill; filtros da lista permanecem single-select na URL e consultam com `{ equals }` (associação ao array — não o operador substring `contains`); UI de `/campanha` com chips multi + bairros só com 1 município; dashboard e criação de liderança atualizados (`Contact.city` só quando `cities.length === 1`). - O DTO paginado de atualizações contém somente filtro/paginação/feed; a visão geral chama um loader dedicado que busca `limit: 3` e apenas os autores necessários à prévia. - A lista paginada de lideranças seleciona e serializa somente os campos da linha; avaliação interna e dados completos de contato são carregados apenas para a liderança selecionada. O fechamento usa `onCloseAutoFocus` no Sheet e `finalFocus` no Drawer, focaliza a linha de origem (ou o título do Núcleo) antes de navegar para a URL canônica, sem RAF/timeout. - Fonte: `/Users/francisco.solla/.cursor/plans/núcleos_eleitorais_mvp_7f788d29.plan.md`. diff --git a/docs/plans/territorio-multi-municipio-bairro.md b/docs/plans/territorio-multi-municipio-bairro.md index 41567b65..0266d1dc 100644 --- a/docs/plans/territorio-multi-municipio-bairro.md +++ b/docs/plans/territorio-multi-municipio-bairro.md @@ -1,8 +1,8 @@ # Território multi-município e multi-bairro -Status: rascunho -Atualizado em: 2026-07-17 -Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos") +Status: **implementado** (2026-07-18) +Atualizado em: 2026-07-18 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A → A1) Responsável: — ## Referência visual (UX Pilot) @@ -20,137 +20,58 @@ Como usar (parte deste plano — bloco Território): ## Contexto -Hoje o território do Núcleo Eleitoral é modelado com campos **single-valued** em `src/collections/ElectoralNucleus.ts`: - -- `region` (Território de Identidade, `text`, único, indexado); -- `city` (Município, `text`, único, indexado); -- `neighborhood` (Bairro, `text`, único); -- `locality` (Localidade, `text`, livre); -- `territoryNotes` (`textarea`). - -As regras atuais (em `validateNucleusTerritoryAndZones` no collection e em `validateGeographyAndZones` no Zod `src/lib/schemas/nucleus.ts`, com parsing em `parseSharedNucleusFormData` em `src/utilities/nucleusUi.ts`) são: - -- `region` deve ser território válido da Bahia; `city` deve ser município válido da Bahia; -- se `region` + `city`, então `city` deve pertencer a `region` (`territoryForCity(city) === region`); -- `neighborhood` exige `city`; -- pelo menos um de `region`/`city`/`locality`. - -Isso não cobre dois casos reais da operação: um núcleo que abrange **mais de um município** (sem ser um Território de Identidade inteiro) e um núcleo que abrange **mais de um bairro** (sem ser um município inteiro). Decisão de produto (2026-07-17): `Município` e `Bairro` passam a aceitar múltiplas entradas, e um núcleo pode **cruzar Territórios de Identidade** quando abrange municípios de territórios distintos. +O MVP modelava o território do Núcleo com campos single-valued (`region` / `city` / `neighborhood`). Isso não cobria núcleos multi-município (incluindo cross-territory) nem multi-bairro. Decisão de produto (2026-07-17): Modelo A — arrays planos — com `regions` derivado no servidor a partir de `cities` quando houver municípios. ## Decisão de modelagem (Modelo A + cross-territory) -Escolhido o **Modelo A** (arrays planos) em vez de array estruturado por município, porque o caso combinado "vários municípios **com** bairros em cada" não é realista para a operação — bairros só fazem sentido escopados a um único município. - -- `region` → **`regions`** (`text[]`): array de Territórios de Identidade. **Derivado no servidor** a partir dos municípios selecionados quando `cities` não vazio; definível manualmente só quando `cities` vazio (caso "núcleo = território inteiro"). -- `city` → **`cities`** (`text[]`): múltiplos municípios, podem pertencer a territórios distintos (cross-territory). -- `neighborhood` → **`neighborhoods`** (`text[]`): múltiplos bairros (texto livre, como hoje). +- `region` → **`regions`** (`text` + `hasMany`, max 27): Territórios de Identidade. **Derivado no servidor** a partir dos municípios quando `cities` não vazio; editável manualmente só quando `cities` vazio. +- `city` → **`cities`** (`text` + `hasMany`, max 27): múltiplos municípios, podem cruzar TIs. +- `neighborhood` → **`neighborhoods`** (`text` + `hasMany`, max 30): múltiplos bairros; exige exatamente 1 município. - `locality` e `territoryNotes`: inalterados. -### Regras de validação (substituem as atuais) +### Regras de validação 1. Pelo menos um de: `regions`, `cities`, `locality`. -2. Cada `city` deve ser município válido da Bahia; cada `region` deve ser território válido. -3. Se `cities` não vazio → `regions` é **exatamente** o conjunto (deduplicado) dos territórios desses municípios. O servidor **ignora** o valor de `regions` enviado e rederiva (mesmo padrão de `setCanonicalNucleusSlug`). -4. Se `cities` vazio → `regions` é o que o usuário escolher (1+ territórios inteiros). -5. Se `neighborhoods` não vazio → `cities` deve ter **exatamente 1** elemento. Bairros só fazem sentido num único município. -6. Bairros: trim, dedup, não vazios. +2. Cada city/region deve ser válido na Bahia. +3. Se `cities` não vazio → `regions = territoriesForCities(cities)` (servidor ignora o valor enviado). +4. Se `cities` vazio → `regions` é a escolha manual (1+ TIs). +5. Se `neighborhoods` não vazio → `cities.length === 1`. +6. Trim + dedup + tetos `maxRows`. -### Diagrama +## Implementação (como ficou) -```mermaid -flowchart TD - Start[Formulário Núcleo] --> HasCities{cities vazio?} - HasCities -->|não| Derive["regions = territórios das cities (servidor)"] - HasCities -->|sim| Manual["regions = escolha manual (1+)"] - Derive --> CheckNh{neighborhoods não vazio?} - Manual --> CheckNh - CheckNh -->|sim| RequireOne["exigir cities.length = 1"] - CheckNh -->|não| Save[persistir] - RequireOne --> Save -``` +### Storage (correção vs rascunho) -## Objetivos +Não usamos Postgres `text[]` + GIN. O Payload 3.82 modela `text` + `hasMany` na tabela compartilhada `electoral_nucleus_texts` (`parent_id` / `path` / `order` / `text`). Isso preserva admin, types e Local API sem schema paralelo. `select` hasMany para `regions` foi rejeitado: criaria enum Postgres com nomes acentuados dos 27 TIs (dados vivos em `bahiaTerritories.ts`). -- Schema do `electoralNucleus` com `regions`/`cities`/`neighborhoods` como arrays, com migration que preserva dados existentes. -- Validação server-side autoritativa (hook `beforeValidate` + Zod) com as 6 regras acima; o cliente apenas reflete o estado derivado. -- Formulário de criar/editar núcleo (`NucleusTerritoryFields`) com município multi-seleção (chips), bairro como tag input habilitado só com 1 município, e território como exibição derivada + seleção manual quando sem municípios. -- Filtros da lista de núcleos (`NucleusFilters`) continuam single-select por território e por município; a consulta muda de `equals` para `contains` (núcleo aparece quando toca aquele território/município). -- View models e DTOs discriminados por papel atualizados para arrays; renderização em lista/detalhe apresenta arrays como badges ou lista separada por vírgula. -- Testes int cobrindo: multi-município, cross-territory, multi-bairro, regra do município único, derivação de `regions`, e filtro `contains`. +### Filtro (correção vs rascunho) -## Decisões travadas +O rascunho dizia `contains`, mas em Payload `contains` é substring. A associação “array contém este valor” usa `{ regions: { equals } }` / `{ cities: { equals } }`. A URL da lista continua single-select (`region` / `city`). -- **Modelo A (arrays planos).** Sem array estruturado por município; sem tabela aninhada. Bairros exigem município único (regra 5). -- **Cross-territory permitido.** Múltiplos municípios podem pertencer a territórios distintos; `regions` reflete o conjunto exato. -- **`regions` derivado no servidor quando há `cities`.** O cliente envia `cities` (e opcionalmente `regions` só para o caso sem municípios); o hook rederiva `regions` a partir de `cities` e ignora o valor enviado quando `cities` não vazio. Mesmo princípio do `slug` canônico. -- **Renomear campos para o plural** (`region`→`regions`, `city`→`cities`, `neighborhood`→`neighborhoods`) para clareza semântica e consistência com identificadores em inglês (AGENTS.md). -- **Migration in-place com backfill.** Dados existentes já cumprem `city ∈ region` (validação atual), então `regions = ARRAY[region]`, `cities = ARRAY[city]`, `neighborhoods = ARRAY[neighborhood]` é correto. Down migration recria colunas single a partir do primeiro elemento e `RAISE EXCEPTION` se algum array tiver >1 elemento (proteção contra perda). -- **Índices GIN em `regions` e `cities`** para o operador `contains` dos filtros. -- **Filtros continuam single-select.** `NucleusFilters` não vira multi-select; só a consulta (`buildNucleusListWhere`) muda de `equals` para `contains`. O estado da URL (`NucleusListState`) mantém `region`/`city` single. -- **Sem `Consent`, sem server action nova, sem collection nova.** Só schema + UI + validação + migration. -- **i18n e naming** seguem AGENTS.md: identificadores em inglês, strings visíveis em pt-BR. -- **Executar cedo, antes do volume de dados reais e antes de Eventos/Demandas** (decisão de sequenciamento 2026-07-17). Três razões: (1) a migration in-place com backfill é trivial e de risco mínimo enquanto há poucos núcleos — cada semana de dados reais aumenta o custo de validação; (2) [`zonas-por-municipio.md`](zonas-por-municipio.md) nasce contra `cities[]` e depende desta ordem; (3) as collections `actionPlan` ([eventos-agenda-mobilizacao.md](eventos-agenda-mobilizacao.md)) e `campaignDemand` ([demandas-campanha.md](demandas-campanha.md)) copiam o bloco de território do núcleo — se nascerem antes desta mudança, herdam o modelo single e criam dívida imediata de migração dupla. +### Arquivos tocados -## Questões em aberto +- Collection + hook: `src/collections/ElectoralNucleus.ts` +- Helper: `territoriesForCities` em `src/lib/bahiaTerritories.ts` +- Zod: `src/lib/schemas/nucleus.ts` +- FormData: `src/utilities/nucleusFormData.ts` + `repeatedFormTexts` em `src/lib/formData.ts` +- Query/VM/UI: `nucleusUi`, `nucleusViewModels`, `nucleusPageData`, dashboard, `NucleusTerritoryFields`, list/detail +- Liderança: `Contact.city` = `cities[0]` só quando `cities.length === 1`, senão `locality ?? null` +- Migration: `src/migrations/20260718_190559_territorio_multi_municipio_bairro.ts` (backfill + down com `RAISE EXCEPTION` se `count > 1`) -- **Tipo do campo Payload.** Para `cities`/`neighborhoods` (texto livre repetido), usar `array` de subcampo `text` (cria tabela `electoral_nucleus_cities`/`electoral_nucleus_neighborhoods`) ou `select` com `hasMany`? `select` não serve para bairros (lista não é fixa). Recomendação: `array` de `text` para `cities` e `neighborhoods`; para `regions` (lista fixa de 27), `select` com `hasMany` ou `array` de `text` com validação — decidir na implementação pelo equilíbrio entre UX no admin Payload e complexidade da migration. -- **Admin Payload UI.** O formulário da campanha é custom React; o admin Payload também edita núcleos. Para `array` de `text`, o admin mostra repeatable text inputs (sem combobox). Aceitável para staff; o combobox bonito fica só no `/campanha`. Confirmar com produto se é suficiente. -- **Detecção de "tseZones alterado" em update** (interação com o plano [Zonas TSE por município](zonas-por-municipio.md)): com múltiplos municípios, o auto-preenchimento de zonas passa a ser a **união** das zonas dos municípios selecionados. Esse plano atualiza a premissa do outro. -- **Limite máximo de municípios/bairros.** Definir um teto razoável (ex.: 27 municípios, 30 bairros) para evitar abuso e limitar o tamanho do array no form/URL. A definir na implementação. -- **Núcleo cross-territory no filtro por território.** Confirmar com produto que um núcleo com municípios em 2 territórios deve aparecer ao filtrar por **qualquer um** dos dois (comportamento esperado do `contains`). +## Questões em aberto — resolvidas na implementação -## Abordagem proposta - -Arquivos: - -- **`src/collections/ElectoralNucleus.ts`**: renomear campos para `regions`/`cities`/`neighborhoods` como arrays; reescrever `validateNucleusTerritoryAndZones` com as 6 regras (incluindo derivação de `regions` a partir de `cities`); atualizar `defaultColumns` para `regions`/`cities`. -- **`src/lib/schemas/nucleus.ts`**: Zod com `regions`/`cities`/`neighborhoods` como arrays (`z.array(z.string())`); `superRefine` com as 6 regras, espelhando o hook. -- **`src/lib/bahiaTerritories.ts`**: adicionar `territoriesForCities(cities: string[]): BahiaIdentityTerritory[]` (conjunto único e ordenado dos territórios dos municípios), reusando `territoryForCity`. -- **`src/utilities/nucleusUi.ts`**: `parseSharedNucleusFormData` lê `cities`/`neighborhoods` repetidos do FormData e deriva `regions` (quando `cities` não vazio); `buildNucleusListWhere` passa a `regions: { contains: state.region }` e `cities: { contains: state.city }`; `NucleusListState` mantém `region`/`city` single (filtro). -- **`src/utilities/nucleusViewModels.ts`**: todos os view models (`NucleusListViewModel`, `NucleusFormViewModel`, `Staff/LeadershipNucleusTabsViewModel`, `NucleusDetailBaseViewModel`) passam a `regions: string[]`, `cities: string[]`, `neighborhoods: string[]`; atualizar mappers (`toNucleusListViewModel`, `toNucleusFormViewModel`, `toNucleusDetailBaseViewModel`, `toStaffNucleusTabsViewModel`, `toLeadershipNucleusTabsViewModel`) e os `select` (`nucleusFormSelect`, `nucleusListSelect`, `nucleusStaffDetailSelect`, `nucleusLeadershipDetailSelect`, `nucleusContextSelect`). -- **`src/utilities/nucleusPageData.ts`**: atualizar `nucleusContextSelect` e os selects por tab para os novos nomes. -- **`src/components/campaign/NucleusTerritoryFields.tsx`**: município → combobox multi-seleção (chips); bairro → tag input habilitado só quando `cities.length === 1` (limpar bairros ao passar para >1); território → exibição derivada (badges dos territórios das cities) + seleção manual só quando sem municípios; hidden inputs emitem `regions`/`cities`/`neighborhoods` repetidos. -- **`src/components/campaign/NucleusFilters.tsx`**: sem mudança de UI; só consome o novo `buildNucleusListWhere`. -- **Componentes de renderização** (`NucleusCard`, `NucleusList`, detalhe overview/territory tab): apresentar `regions`/`cities`/`neighborhoods` como badges ou lista separada por vírgula. -- **`src/payload-types.ts`**: regenerar com `pnpm generate:types`. -- **Migration** `src/migrations/_territorio_multi_municipio_bairro.ts` (+ `.json` + `index.ts`): ver seção Migration. - -## Migration - -`pnpm migrate:create territorio_multi_municipio_bairro` e editar à mão (skill `payload-migrations` permite data migration): - -- adicionar colunas `regions text[]`, `cities text[]`, `neighborhoods text[]` em `electoral_nucleus`; -- backfill: - - `regions = CASE WHEN region IS NOT NULL THEN ARRAY[region] ELSE ARRAY[]::text[] END`; - - `cities = CASE WHEN city IS NOT NULL THEN ARRAY[city] ELSE ARRAY[]::text[] END`; - - `neighborhoods = CASE WHEN neighborhood IS NOT NULL THEN ARRAY[neighborhood] ELSE ARRAY[]::text[] END`; - - (dados existentes já cumprem `city ∈ region`, então `regions = ARRAY[region]` está correto); -- drop colunas antigas `region`/`city`/`neighborhood`; -- drop índices antigos `electoral_nucleus_region_idx` e criar GIN em `regions` e `cities` para o operador `contains`; -- **down**: recriar colunas single `region`/`city`/`neighborhood` a partir do primeiro elemento de cada array, recriar índice btree, e `RAISE EXCEPTION` se algum array tiver `cardinality > 1` (proteção contra perda de dados multi-município/bairro). +| Questão | Resolução | +| --- | --- | +| Tipo do campo Payload | `text` + `hasMany` para os três campos | +| Admin Payload UI | inputs nativos hasMany; combobox rico só em `/campanha` | +| Limite máximo | 27 municípios/TIs, 30 bairros | +| Filtro cross-territory | aparece ao filtrar por qualquer TI tocado (`equals`) | ## Dependências -- Reusa `territoryForCity`/`citiesForTerritory`/`bahiaMunicipalities` (`src/lib/bahiaTerritories.ts`), `CitiesByState.BA` (`src/lib/cities`), `StrictCombobox` (`src/components/campaign/StrictCombobox`), `municipalityComboboxOptions`/`territoryComboboxOptions` (`src/utilities/territoryComboboxOptions`). -- **Interage com o plano [Zonas TSE por município](zonas-por-municipio.md)**: o auto-preenchimento de zonas passa a ser a união das zonas dos municípios selecionados (em vez de "a zona do município"). Atualizar a premissa daquele plano quando ambos forem implementados. +- A2 ([zonas-por-municipio.md](zonas-por-municipio.md)) nasce contra `cities[]` (união das zonas). +- C3/C4 (eventos/demandas) devem copiar o bloco multi de território. ## Não escopo -- Array estruturado por município (`areas: [{ city, neighborhoods }]`) — caso combinado "vários municípios com bairros em cada" fica fora. -- Mapa / PostGIS / polígonos GeoJSON (roadmap "Mapa / PostGIS"). -- Polígonos de zonas TSE — só o cadastro tabular (ver plano [Zonas TSE por município](zonas-por-municipio.md)). -- Multi-tenant / white-label. -- Alterar `Contact.city` (que virou opcional para representar território sem município) — escopo de `Contact`, não do núcleo. - -## Referências - -- `docs/roadmap.md` (item novo; seção "Campanha → Próximos ciclos") -- `.cursor/rules/projects/nucleos-eleitorais.mdc` (decisão a registrar) -- `src/collections/ElectoralNucleus.ts` — campos e `validateNucleusTerritoryAndZones` -- `src/lib/schemas/nucleus.ts` — `validateGeographyAndZones` -- `src/utilities/nucleusUi.ts` — `parseSharedNucleusFormData`, `buildNucleusListWhere` -- `src/utilities/nucleusViewModels.ts` — view models e selects -- `src/components/campaign/NucleusTerritoryFields.tsx`, `NucleusFilters.tsx` — UI -- `src/lib/bahiaTerritories.ts` — `territoryForCity`, `citiesForTerritory` -- `docs/plans/zonas-por-municipio.md` — premissa de auto-preenchimento de zonas a atualizar -- AGENTS.md — naming conventions, padrão de migration, "Bahia implícita no Núcleo" +Array estruturado por município; mapa/PostGIS; auto-preenchimento A2; mudar `Contact.city` além da regra de derivação na criação de liderança. diff --git a/docs/plans/zonas-por-municipio.md b/docs/plans/zonas-por-municipio.md index abfeb079..102cc915 100644 --- a/docs/plans/zonas-por-municipio.md +++ b/docs/plans/zonas-por-municipio.md @@ -1,10 +1,14 @@ # Zonas TSE por município (auto-preenchimento) Status: rascunho -Atualizado em: 2026-07-17 -Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos") +Atualizado em: 2026-07-18 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A → A2) Responsável: — +## Premissa atualizada (A1 entregue) + +A1 ([territorio-multi-municipio-bairro.md](territorio-multi-municipio-bairro.md)) renomeou o território do núcleo para arrays `regions` / `cities` / `neighborhoods` (`text` + `hasMany`). Este plano **nasce contra `cities[]`**: o auto-preenchimento de zonas passa a ser a **união** das zonas oficiais de todos os municípios selecionados (não “a zona do município”). Bairros continuam a exigir exatamente um município (regra A1), então os modos com bairro permanecem escopados a um único município. + ## Referência visual (UX Pilot) Design: [`Formulario-Territorio.png`](../design-refs/latest/Formulario-Territorio.png) · [`Formulario-Territorio.html`](../design-refs/latest/Formulario-Territorio.html) — **compartilhado com [territorio-multi-municipio-bairro.md](territorio-multi-municipio-bairro.md)** (a mesma tela cobre os dois planos). @@ -20,7 +24,7 @@ Como usar (parte deste plano — seção "Zonas Eleitorais (TSE)"): ## Contexto -Hoje o campo **Zonas TSE** do formulário do núcleo (`TseZoneInput` em `src/components/campaign/TseZoneInput.tsx`) é sempre de preenchimento manual livre: o usuário digita números de 1 a 999, separados por vírgula/espaço/Enter, e o servidor só valida unicidade e intervalo (`parseTseZoneNumbers` em `src/utilities/nucleusUi.ts`, `validateNucleusTerritoryAndZones` em `src/collections/ElectoralNucleus.ts`, `validateGeographyAndZones` em `src/lib/schemas/nucleus.ts`). Não há nenhuma relação entre o município selecionado e as zonas informadas — o usuário precisa saber (ou adivinhar) quais zonas cobrem aquele município. +Hoje o campo **Zonas TSE** do formulário do núcleo (`TseZoneInput` em `src/components/campaign/TseZoneInput.tsx`) é sempre de preenchimento manual livre: o usuário digita números de 1 a 999, separados por vírgula/espaço/Enter, e o servidor só valida unicidade e intervalo (`parseTseZoneNumbers` em `src/utilities/tseZone.ts`, `validateNucleusTerritoryAndZones` em `src/collections/ElectoralNucleus.ts`). Não há nenhuma relação entre o(s) município(s) selecionado(s) e as zonas informadas — o usuário precisa saber (ou adivinhar) quais zonas cobrem aquele território. O TSE publica, como dado aberto, a correspondência oficial entre município e zona eleitoral (cada par município×zona aparece no conjunto "Eleitorado por município e zona", por UF). A decisão de produto (2026-07-17) é usar esse cadastro oficial para **preencher automaticamente** as Zonas TSE do núcleo a partir do município selecionado, sem edição manual nesse caso. O território de identidade já é auto-selecionado hoje quando o município muda (`territoryForCity` em `src/lib/bahiaTerritories.ts`, já wired em `NucleusTerritoryFields`); este plano preserva esse comportamento e o documenta como parte do mesmo fluxo. diff --git a/docs/roadmap.md b/docs/roadmap.md index 3b154805..b2b1e0f9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -139,7 +139,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): A1, B1, B2, | Ordem | Item | Plano | Depende de | Paralelizável com | | ----- | ----------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ----------------- | | 1 | Onda 0 (jurídico em lote + deploy + lançamento) | — | externo | tudo | -| 2 | A1 Território multi-município/bairro | [detalhes](plans/territorio-multi-municipio-bairro.md) | — (executar cedo: migration barata antes do volume de dados; pré-requisito de A2, C3, C4) | B1, C1, A3 | +| 2 | A1 Território multi-município/bairro **(entregue 2026-07-18)** | [detalhes](plans/territorio-multi-municipio-bairro.md) | — | B1, C1, A3 | | 3 | A2 Zonas TSE por município (auto-preenchimento) | [detalhes](plans/zonas-por-municipio.md) | A1 (nasce contra `cities[]`) | B1, C1, A3 | | 4 | B1 Overview da lista de núcleos | [detalhes](plans/overview-lista-nucleos.md) | — | A1, A2, C1 | | 5 | C1 Compartilhar página (quick win de campo) | [detalhes](plans/compartilhar-pagina.md) | — | tudo | diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx index 451aae6d..ffd69cdf 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx @@ -35,7 +35,7 @@ import { resolveNucleusDetailTab, } from '@/utilities/nucleusDetailTabUi' import { NucleusNotFoundError } from '@/utilities/nucleusPageData' -import { organizationKindLabels } from '@/utilities/nucleusUi' +import { formatNucleusTerritoryLabel, organizationKindLabels } from '@/utilities/nucleusUi' type NucleusDetailPageProps = { params: Promise<{ slug: string }> @@ -80,9 +80,7 @@ export default async function NucleusDetailPage({ params, searchParams }: Nucleu null, isStaffView ? view.proposedVoteEstimateVersion : null, ) - const territory = [view.neighborhood, view.locality, view.city, view.region] - .filter(Boolean) - .join(' · ') + const territory = formatNucleusTerritoryLabel(view) const voteEstimateFocusTargetId = `vote-estimate-${view.id}-focus-target` return ( diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts index ce1d5db8..78647e74 100644 --- a/src/app/(campaign)/campanha/actions/leadership.ts +++ b/src/app/(campaign)/campanha/actions/leadership.ts @@ -94,7 +94,8 @@ const createValidatedLeadershipRecord = async ( const contactReused = Boolean(contactID) if (!contactID) { - const city = nucleus.city ?? nucleus.locality + const cities = Array.isArray(nucleus.cities) ? nucleus.cities : [] + const city = cities.length === 1 ? cities[0] : (nucleus.locality ?? null) const contact = await payload.create({ collection: 'contact', data: { diff --git a/src/collections/ElectoralNucleus.ts b/src/collections/ElectoralNucleus.ts index 427dd2b6..2d6fecc9 100644 --- a/src/collections/ElectoralNucleus.ts +++ b/src/collections/ElectoralNucleus.ts @@ -4,8 +4,13 @@ import { APIError } from 'payload' import { isBahiaIdentityTerritory, isBahiaMunicipality, - validateBahiaTerritoryPair, + territoriesForCities, } from '@/lib/bahiaTerritories' +import { + MAX_NUCLEUS_CITIES, + MAX_NUCLEUS_NEIGHBORHOODS, + MAX_NUCLEUS_REGIONS, +} from '@/lib/schemas/nucleus' import { canCreateElectoralNucleus, canCreateNucleusCoordinators, @@ -24,6 +29,31 @@ 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, @@ -32,34 +62,57 @@ const validateNucleusTerritoryAndZones: CollectionBeforeValidateHook = ({ if (!data) return data const nextData = operation === 'update' ? { ...originalDoc, ...data } : data - const region = trimmedText(nextData?.region) - const city = trimmedText(nextData?.city) - const neighborhood = trimmedText(nextData?.neighborhood) + 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 (region && !isBahiaIdentityTerritory(region)) { - throw new APIError('Selecione um território de identidade válido da Bahia.', 400) + if (cities.length > MAX_NUCLEUS_CITIES) { + throw new APIError(`Informe no máximo ${MAX_NUCLEUS_CITIES} municípios.`, 400) } - if (city && !isBahiaMunicipality(city)) { - throw new APIError('Selecione um município válido da Bahia.', 400) + if (regions.length > MAX_NUCLEUS_REGIONS) { + throw new APIError(`Informe no máximo ${MAX_NUCLEUS_REGIONS} territórios.`, 400) } - if (!validateBahiaTerritoryPair(region, city)) { - throw new APIError('O município não pertence ao território de identidade selecionado.', 400) + if (neighborhoods.length > MAX_NUCLEUS_NEIGHBORHOODS) { + throw new APIError(`Informe no máximo ${MAX_NUCLEUS_NEIGHBORHOODS} bairros.`, 400) } - if (neighborhood && !city) { - throw new APIError('Informe o município antes do bairro.', 400) + + for (const city of cities) { + if (!isBahiaMunicipality(city)) { + throw new APIError('Selecione um município válido da Bahia.', 400) + } } - if (!region && !city && !locality) { + + 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.region = region || null - data.city = city || null - data.neighborhood = neighborhood || null + data.regions = regions + data.cities = cities + data.neighborhoods = cities.length === 1 ? neighborhoods : [] data.locality = locality || null data.territoryNotes = territoryNotes || null @@ -131,7 +184,7 @@ export const ElectoralNucleus: CollectionConfig = { admin: { group: 'Campanha', useAsTitle: 'name', - defaultColumns: ['name', 'status', 'region', 'city', 'updatedAt'], + defaultColumns: ['name', 'status', 'regions', 'cities', 'updatedAt'], }, access: { create: canCreateElectoralNucleus, @@ -247,29 +300,40 @@ export const ElectoralNucleus: CollectionConfig = { filterOptions: eligibleNucleusCoordinatorWhere, }, { - name: 'region', + name: 'regions', type: 'text', - label: 'Território de identidade', + label: 'Territórios de identidade', + hasMany: true, index: true, - validate: (value: unknown) => - value === null || - value === undefined || - (typeof value === 'string' && isBahiaIdentityTerritory(value)) + 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.', + : 'Selecione um território de identidade válido da Bahia.' + }, }, { - name: 'city', + name: 'cities', type: 'text', - label: 'Município', + label: 'Municípios', + hasMany: true, maxLength: 120, index: true, + maxRows: MAX_NUCLEUS_CITIES, }, { - name: 'neighborhood', + name: 'neighborhoods', type: 'text', - label: 'Bairro', + label: 'Bairros', + hasMany: true, maxLength: 160, + maxRows: MAX_NUCLEUS_NEIGHBORHOODS, }, { name: 'locality', diff --git a/src/components/campaign/NucleusActiveTab.tsx b/src/components/campaign/NucleusActiveTab.tsx index 9cf8414f..6bd38670 100644 --- a/src/components/campaign/NucleusActiveTab.tsx +++ b/src/components/campaign/NucleusActiveTab.tsx @@ -28,6 +28,7 @@ import { type NucleusDetailTab, } from '@/utilities/nucleusDetailTabUi' import type { AccessibleNucleusContext } from '@/utilities/nucleusPageData' +import { formatNucleusTerritoryLabel } from '@/utilities/nucleusUi' import type { NucleusDetailViewModel, NucleusTabsViewModel, @@ -190,9 +191,7 @@ const OverviewContent = ({

- {[nucleus.neighborhood, nucleus.locality, nucleus.city, nucleus.region] - .filter(Boolean) - .join(' · ')} + {formatNucleusTerritoryLabel(nucleus)}

@@ -252,9 +251,7 @@ const TerritoryContent = ({ nucleus }: { nucleus: NucleusTabsViewModel }) => (

- {[nucleus.neighborhood, nucleus.locality, nucleus.city, nucleus.region] - .filter(Boolean) - .join(' · ')} + {formatNucleusTerritoryLabel(nucleus)}

{nucleus.kind === 'staff' && nucleus.territoryNotes ? (

{nucleus.territoryNotes}

diff --git a/src/components/campaign/NucleusList.tsx b/src/components/campaign/NucleusList.tsx index b9a7abe8..fb7276b4 100644 --- a/src/components/campaign/NucleusList.tsx +++ b/src/components/campaign/NucleusList.tsx @@ -14,7 +14,7 @@ import { TableHeader, TableRow, } from '@/components/ui/Table' -import { organizationKindLabels } from '@/utilities/nucleusUi' +import { formatNucleusTerritoryLabel, organizationKindLabels } from '@/utilities/nucleusUi' import type { NucleusListViewModel } from '@/utilities/nucleusViewModels' export type NucleusListProps = { @@ -32,7 +32,7 @@ const getInitials = (name: string): string => .toUpperCase() const getTerritory = (nucleus: NucleusListViewModel): string => - [nucleus.neighborhood, nucleus.locality, nucleus.city, nucleus.region].filter(Boolean).join(' · ') + formatNucleusTerritoryLabel(nucleus) const getOrganization = (nucleus: NucleusListViewModel): string => nucleus.organizationLabel ?? organizationKindLabels[nucleus.organizationKind] diff --git a/src/components/campaign/NucleusTerritoryFields.tsx b/src/components/campaign/NucleusTerritoryFields.tsx index e1b530db..8926807b 100644 --- a/src/components/campaign/NucleusTerritoryFields.tsx +++ b/src/components/campaign/NucleusTerritoryFields.tsx @@ -1,22 +1,62 @@ 'use client' -import { useState } from 'react' +import { useMemo, useState } from 'react' +import { XIcon } from 'lucide-react' import { StrictCombobox } from '@/components/campaign/StrictCombobox' -import { Field, FieldError, FieldLabel } from '@/components/ui/field' -import { Input } from '@/components/ui/input' -import { territoryForCity } from '@/lib/bahiaTerritories' +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 { + MAX_NUCLEUS_CITIES, + MAX_NUCLEUS_NEIGHBORHOODS, + MAX_NUCLEUS_REGIONS, +} from '@/lib/schemas/nucleus' import { municipalityComboboxOptions, territoryComboboxOptions, } from '@/utilities/territoryComboboxOptions' type TerritoryValues = { - region?: string | null - city?: string | null - neighborhood?: string | null + regions?: string[] | null + cities?: string[] | null + neighborhoods?: string[] | null } +const uniquePreserveOrder = (values: string[]): string[] => { + const seen = new Set() + const result: string[] = [] + for (const value of values) { + const trimmed = value.trim() + if (!trimmed || seen.has(trimmed)) continue + seen.add(trimmed) + result.push(trimmed) + } + return result +} + +const ChipList = ({ + values, + onRemove, + removeLabel, +}: { + values: string[] + onRemove: (value: string) => void + removeLabel: (value: string) => string +}) => ( +
+ {values.map((value) => ( + + + + ))} +
+) + export const NucleusTerritoryFields = ({ values, fieldErrors = {}, @@ -24,86 +64,240 @@ export const NucleusTerritoryFields = ({ values?: TerritoryValues fieldErrors?: Record }) => { - const [region, setRegion] = useState(values?.region ?? '') - const [city, setCity] = useState(values?.city ?? '') - const [neighborhood, setNeighborhood] = useState(values?.neighborhood ?? '') + const [regions, setRegions] = useState(() => uniquePreserveOrder(values?.regions ?? [])) + const [cities, setCities] = useState(() => uniquePreserveOrder(values?.cities ?? [])) + const [neighborhoods, setNeighborhoods] = useState(() => + uniquePreserveOrder(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 visibleRegionError = regionError ?? errorFor('region') - const visibleCityError = cityError ?? errorFor('city') - const cityOptions = municipalityComboboxOptions(region) + const derivedRegions = useMemo(() => territoriesForCities(cities), [cities]) + const displayRegions = cities.length > 0 ? derivedRegions : regions + const regionsAreDerived = cities.length > 0 + const neighborhoodsEnabled = cities.length === 1 + + const visibleRegionError = regionError ?? errorFor('regions') + const visibleCityError = cityError ?? errorFor('cities') + const visibleNeighborhoodError = neighborhoodError ?? errorFor('neighborhoods') + + const availableCityOptions = municipalityComboboxOptions().filter( + (option) => !cities.includes(option.value), + ) + const availableRegionOptions = territoryComboboxOptions.filter( + (option) => !regions.includes(option.value), + ) + + 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 = uniquePreserveOrder([...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) => uniquePreserveOrder([...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) => uniquePreserveOrder([...current, trimmed])) + setNeighborhoodDraft('') + setNeighborhoodError(undefined) + } return (
- - - - Território de identidade - { - if (city && nextRegion && territoryForCity(city) !== nextRegion) { - setCity('') - setNeighborhood('') - } - setRegion(nextRegion) - setRegionError(undefined) - setCityError(undefined) - }} - onInvalid={() => { - setRegionError('Selecione um território de identidade válido da Bahia.') - }} - error={visibleRegionError} - /> + {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ípio + + Municípios + {cities.length ? ( + `Remover município ${city}`} + /> + ) : null} { - if (nextCity !== city) setNeighborhood('') - setCity(nextCity) - setCityError(undefined) - const nextRegion = territoryForCity(nextCity) - if (nextRegion) { - setRegion(nextRegion) - setRegionError(undefined) - } + if (nextCity) addCity(nextCity) + else setCityDraft('') }} onInvalid={() => { - setNeighborhood('') 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} - - Bairro - setNeighborhood(event.target.value)} - disabled={!city.trim()} - autoComplete="address-level3" - aria-invalid={Boolean(errorFor('neighborhood'))} - aria-describedby={errorFor('neighborhood') ? 'neighborhood-error' : undefined} - /> - {errorFor('neighborhood') ? ( - {errorFor('neighborhood')} + + 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/lib/bahiaTerritories.ts b/src/lib/bahiaTerritories.ts index d870bc7e..bae2bd04 100644 --- a/src/lib/bahiaTerritories.ts +++ b/src/lib/bahiaTerritories.ts @@ -649,3 +649,12 @@ export const validateBahiaTerritoryPair = ( export const citiesForTerritory = (territory: BahiaIdentityTerritory): readonly string[] => bahiaIdentityTerritoryRecords.find(({ name }) => name === territory)?.municipalities ?? [] + +export const territoriesForCities = (cities: readonly string[]): BahiaIdentityTerritory[] => { + const territories = new Set() + for (const city of cities) { + const territory = territoryForCity(city) + if (territory) territories.add(territory) + } + return bahiaIdentityTerritories.filter((territory) => territories.has(territory)) +} diff --git a/src/lib/formData.ts b/src/lib/formData.ts index 7d423ae2..d502374b 100644 --- a/src/lib/formData.ts +++ b/src/lib/formData.ts @@ -114,6 +114,21 @@ export const repeatedRelationshipFormValues = (formData: FormData, field: string return [...new Set(values)].sort((left, right) => left - right) } +export const repeatedFormTexts = (formData: FormData, field: string): string[] => { + const seen = new Set() + const values: string[] = [] + for (const value of formData.getAll(field)) { + if (typeof value !== 'string') { + throw new FormDataBoundaryError(field, 'Arquivos não são aceitos neste campo.') + } + const trimmed = value.trim() + if (!trimmed || seen.has(trimmed)) continue + seen.add(trimmed) + values.push(trimmed) + } + return values +} + export const checkboxFormValue = (formData: FormData, field: string): boolean => { const entry = formEntry(formData, field) if (!entry.present) return false diff --git a/src/lib/schemas/nucleus.ts b/src/lib/schemas/nucleus.ts index 1acc04c3..33cf066a 100644 --- a/src/lib/schemas/nucleus.ts +++ b/src/lib/schemas/nucleus.ts @@ -1,9 +1,9 @@ import { z } from 'zod' import { - bahiaIdentityTerritories, + isBahiaIdentityTerritory, isBahiaMunicipality, - validateBahiaTerritoryPair, + territoriesForCities, } from '@/lib/bahiaTerritories' import { positiveRelationshipId, @@ -33,15 +33,58 @@ export const sectorKinds = [ 'outro', ] as const -const optionalBahiaCity = trimmedOptionalText(120).refine( - (value) => value === undefined || isBahiaMunicipality(value), - 'Selecione um município válido da Bahia.', -) +export const MAX_NUCLEUS_REGIONS = 27 +export const MAX_NUCLEUS_CITIES = 27 +export const MAX_NUCLEUS_NEIGHBORHOODS = 30 -const nullableBahiaCity = trimmedNullableText(120).refine( - (value) => value === undefined || value === null || isBahiaMunicipality(value), - 'Selecione um município válido da Bahia.', -) +const dedupeTrimmedStrings = (values: string[]): string[] => { + const seen = new Set() + const result: string[] = [] + for (const value of values) { + const trimmed = value.trim() + if (!trimmed || seen.has(trimmed)) continue + seen.add(trimmed) + result.push(trimmed) + } + return result +} + +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 tseZoneSchema = z.object({ zoneNumber: z.number().int().min(1).max(999), @@ -70,9 +113,9 @@ const ticketAllianceSchema = z.object({ const nucleusFieldsSchema = z.object({ name: z.string().trim().min(2).max(160), - region: z.enum(bahiaIdentityTerritories).optional(), - city: optionalBahiaCity, - neighborhood: trimmedOptionalText(160), + regions: regionsArraySchema.optional(), + cities: citiesArraySchema.optional(), + neighborhoods: neighborhoodsArraySchema.optional(), locality: trimmedOptionalText(160), territoryNotes: trimmedOptionalText(2000), organizationKind: z.enum(organizationKinds), @@ -87,9 +130,9 @@ const nucleusFieldsSchema = z.object({ }) type TerritoryValidationInput = { - region?: (typeof bahiaIdentityTerritories)[number] | null - city?: string | null - neighborhood?: string | null + regions?: string[] | null + cities?: string[] | null + neighborhoods?: string[] | null locality?: string | null tseZones?: Array<{ zoneNumber: number }> } @@ -99,37 +142,59 @@ const validateTerritoryAndZones = ( 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.region !== undefined && data.city !== undefined && data.locality !== undefined + data.regions !== undefined && data.cities !== undefined && data.locality !== undefined if ( - !data.region && - !data.city && - !data.locality && + 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 núcleo.', - path: ['city'], + path: ['cities'], }) } - const neighborhoodLacksCity = - mode === 'create' ? !data.city : data.city === null - if (data.neighborhood && neighborhoodLacksCity) { + if (neighborhoods.length > 0 && cities.length !== 1) { context.addIssue({ code: 'custom', - message: 'Informe o município antes do bairro.', - path: ['neighborhood'], + message: + cities.length === 0 + ? 'Informe o município antes do bairro.' + : 'Bairros só podem ser informados quando há exatamente um município.', + path: ['neighborhoods'], }) } - if (!validateBahiaTerritoryPair(data.region, data.city)) { - context.addIssue({ - code: 'custom', - message: 'O município não pertence ao território de identidade selecionado.', - path: ['city'], - }) + if (cities.length > 0) { + for (const city of cities) { + if (!isBahiaMunicipality(city)) { + context.addIssue({ + code: 'custom', + message: 'Selecione um município válido da Bahia.', + path: ['cities'], + }) + break + } + } + } else { + for (const region of regions) { + if (!isBahiaIdentityTerritory(region)) { + context.addIssue({ + code: 'custom', + message: 'Selecione um território de identidade válido da Bahia.', + path: ['regions'], + }) + break + } + } } const zoneNumbers = data.tseZones?.map(({ zoneNumber }) => zoneNumber) ?? [] @@ -148,14 +213,26 @@ export const nucleusCreateSchema = nucleusFieldsSchema organizationKind: z.enum(organizationKinds).default('territorial'), }) .superRefine((data, context) => validateTerritoryAndZones(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, + } + }) export const nucleusUpdateSchema = nucleusFieldsSchema .partial() .extend({ id: positiveRelationshipId, - region: z.enum(bahiaIdentityTerritories).nullable().optional(), - city: nullableBahiaCity, - neighborhood: trimmedNullableText(160), + regions: regionsArraySchema.nullable().optional(), + cities: citiesArraySchema.nullable().optional(), + neighborhoods: neighborhoodsArraySchema.nullable().optional(), locality: trimmedNullableText(160), territoryNotes: trimmedNullableText(2000), organizationLabel: trimmedNullableText(160), @@ -171,6 +248,32 @@ export const nucleusUpdateSchema = nucleusFieldsSchema .optional(), }) .superRefine((data, context) => validateTerritoryAndZones(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 NucleusCreateInput = z.input export type NucleusUpdateInput = z.input diff --git a/src/migrations/20260718_190559_territorio_multi_municipio_bairro.json b/src/migrations/20260718_190559_territorio_multi_municipio_bairro.json new file mode 100644 index 00000000..f21e1887 --- /dev/null +++ b/src/migrations/20260718_190559_territorio_multi_municipio_bairro.json @@ -0,0 +1,6585 @@ +{ + "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.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 + }, + "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_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_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_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": "720091c3-b5c0-4606-af67-e1f52d8eee5a", + "prevId": "00000000-0000-0000-0000-000000000000" +} \ No newline at end of file diff --git a/src/migrations/20260718_190559_territorio_multi_municipio_bairro.ts b/src/migrations/20260718_190559_territorio_multi_municipio_bairro.ts new file mode 100644 index 00000000..a4791121 --- /dev/null +++ b/src/migrations/20260718_190559_territorio_multi_municipio_bairro.ts @@ -0,0 +1,147 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'electoral_nucleus_texts' + ) THEN + CREATE TABLE "electoral_nucleus_texts" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer NOT NULL, + "parent_id" integer NOT NULL, + "path" varchar NOT NULL, + "text" varchar + ); + + ALTER TABLE "electoral_nucleus_texts" + ADD CONSTRAINT "electoral_nucleus_texts_parent_fk" + FOREIGN KEY ("parent_id") REFERENCES "public"."electoral_nucleus"("id") + ON DELETE cascade ON UPDATE no action; + + CREATE INDEX "electoral_nucleus_texts_order_parent" + ON "electoral_nucleus_texts" USING btree ("order", "parent_id"); + CREATE INDEX "electoral_nucleus_texts_parent_idx" + ON "electoral_nucleus_texts" USING btree ("parent_id"); + CREATE INDEX "electoral_nucleus_texts_path_idx" + ON "electoral_nucleus_texts" USING btree ("path"); + CREATE INDEX "electoral_nucleus_texts_text_idx" + ON "electoral_nucleus_texts" USING btree ("text"); + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'electoral_nucleus' + AND column_name = 'region' + ) THEN + INSERT INTO "electoral_nucleus_texts" ("order", "parent_id", "path", "text") + SELECT 1, "id", 'regions', "region" + FROM "electoral_nucleus" + WHERE "region" IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM "electoral_nucleus_texts" existing + WHERE existing."parent_id" = "electoral_nucleus"."id" + AND existing."path" = 'regions' + ); + + INSERT INTO "electoral_nucleus_texts" ("order", "parent_id", "path", "text") + SELECT 1, "id", 'cities', "city" + FROM "electoral_nucleus" + WHERE "city" IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM "electoral_nucleus_texts" existing + WHERE existing."parent_id" = "electoral_nucleus"."id" + AND existing."path" = 'cities' + ); + + INSERT INTO "electoral_nucleus_texts" ("order", "parent_id", "path", "text") + SELECT 1, "id", 'neighborhoods', "neighborhood" + FROM "electoral_nucleus" + WHERE "neighborhood" IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM "electoral_nucleus_texts" existing + WHERE existing."parent_id" = "electoral_nucleus"."id" + AND existing."path" = 'neighborhoods' + ); + + DROP INDEX IF EXISTS "electoral_nucleus_region_idx"; + DROP INDEX IF EXISTS "electoral_nucleus_city_idx"; + ALTER TABLE "electoral_nucleus" DROP COLUMN "region"; + ALTER TABLE "electoral_nucleus" DROP COLUMN "city"; + ALTER TABLE "electoral_nucleus" DROP COLUMN "neighborhood"; + END IF; + END $$; + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'electoral_nucleus_texts' + ) THEN + IF EXISTS ( + SELECT 1 + FROM "electoral_nucleus_texts" + GROUP BY "parent_id", "path" + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION + 'Cannot down-migrate territorio_multi_municipio_bairro: one or more nuclei have multiple regions, cities, or neighborhoods.'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'electoral_nucleus' + AND column_name = 'region' + ) THEN + ALTER TABLE "electoral_nucleus" ADD COLUMN "region" varchar; + ALTER TABLE "electoral_nucleus" ADD COLUMN "city" varchar; + ALTER TABLE "electoral_nucleus" ADD COLUMN "neighborhood" varchar; + END IF; + + UPDATE "electoral_nucleus" nucleus + SET "region" = texts."text" + FROM "electoral_nucleus_texts" texts + WHERE texts."parent_id" = nucleus."id" + AND texts."path" = 'regions' + AND texts."order" = 1; + + UPDATE "electoral_nucleus" nucleus + SET "city" = texts."text" + FROM "electoral_nucleus_texts" texts + WHERE texts."parent_id" = nucleus."id" + AND texts."path" = 'cities' + AND texts."order" = 1; + + UPDATE "electoral_nucleus" nucleus + SET "neighborhood" = texts."text" + FROM "electoral_nucleus_texts" texts + WHERE texts."parent_id" = nucleus."id" + AND texts."path" = 'neighborhoods' + AND texts."order" = 1; + + DROP TABLE "electoral_nucleus_texts" CASCADE; + CREATE INDEX IF NOT EXISTS "electoral_nucleus_region_idx" + ON "electoral_nucleus" USING btree ("region"); + CREATE INDEX IF NOT EXISTS "electoral_nucleus_city_idx" + ON "electoral_nucleus" USING btree ("city"); + END IF; + END $$; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 71d903d8..1312ee77 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -4,6 +4,7 @@ import * as migration_20260715_181058_add_post_and_tag from './20260715_181058_a import * as migration_20260715_215834_rename_tag_visible_to_hidden from './20260715_215834_rename_tag_visible_to_hidden'; import * as migration_20260716_010420_add_campaign_user from './20260716_010420_add_campaign_user'; import * as migration_20260718_010733_consolidate_campaign_schema from './20260718_010733_consolidate_campaign_schema'; +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'; export const migrations = [ @@ -37,10 +38,14 @@ export const migrations = [ down: migration_20260718_010733_consolidate_campaign_schema.down, name: '20260718_010733_consolidate_campaign_schema', }, + { + up: migration_20260718_190559_territorio_multi_municipio_bairro.up, + down: migration_20260718_190559_territorio_multi_municipio_bairro.down, + name: '20260718_190559_territorio_multi_municipio_bairro', + }, { up: migration_20260718_195854_add_election_results.up, down: migration_20260718_195854_add_election_results.down, name: '20260718_195854_add_election_results', }, ] - diff --git a/src/payload-types.ts b/src/payload-types.ts index fdf8f0ea..24b9b9f8 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -351,9 +351,9 @@ export interface ElectoralNucleus { slug: string; status: 'ativo' | 'arquivado'; coordinators?: (number | CampaignUser)[] | null; - region?: string | null; - city?: string | null; - neighborhood?: string | null; + regions?: string[] | null; + cities?: string[] | null; + neighborhoods?: string[] | null; locality?: string | null; territoryNotes?: string | null; organizationKind: @@ -994,9 +994,9 @@ export interface ElectoralNucleusSelect { slug?: T; status?: T; coordinators?: T; - region?: T; - city?: T; - neighborhood?: T; + regions?: T; + cities?: T; + neighborhoods?: T; locality?: T; territoryNotes?: T; organizationKind?: T; diff --git a/src/utilities/campaignDashboardPageData.ts b/src/utilities/campaignDashboardPageData.ts index 570d41ad..880fc759 100644 --- a/src/utilities/campaignDashboardPageData.ts +++ b/src/utilities/campaignDashboardPageData.ts @@ -15,9 +15,9 @@ import { requireRelationshipId } from '@/utilities/relationship' const dashboardStaffNucleusSelect = { name: true, slug: true, - region: true, - city: true, - neighborhood: true, + regions: true, + cities: true, + neighborhoods: true, locality: true, organizationKind: true, organizationLabel: true, @@ -31,9 +31,9 @@ const dashboardStaffNucleusSelect = { const dashboardLeadershipNucleusSelect = { name: true, slug: true, - region: true, - city: true, - neighborhood: true, + regions: true, + cities: true, + neighborhoods: true, locality: true, organizationKind: true, organizationLabel: true, @@ -51,9 +51,9 @@ type RawDashboardNucleus = { id: number name: string slug: string - region?: DashboardNucleusRecord['region'] - city?: string | null - neighborhood?: string | null + regions?: string[] | null + cities?: string[] | null + neighborhoods?: string[] | null locality?: string | null organizationKind: DashboardNucleusRecord['organizationKind'] organizationLabel?: string | null @@ -64,6 +64,9 @@ type RawDashboardNucleus = { lastUpdateAt?: string | null } +const asStringArray = (value: string[] | null | undefined): string[] => + Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] + type RawDashboardLeadership = { nucleus: number | { id: number } supportStatus?: unknown @@ -76,9 +79,9 @@ const toDashboardNucleusRecord = ( id: nucleus.id, name: nucleus.name, slug: nucleus.slug, - region: nucleus.region ?? null, - city: nucleus.city ?? null, - neighborhood: nucleus.neighborhood ?? null, + regions: asStringArray(nucleus.regions), + cities: asStringArray(nucleus.cities), + neighborhoods: asStringArray(nucleus.neighborhoods), locality: nucleus.locality ?? null, organizationKind: nucleus.organizationKind, organizationLabel: nucleus.organizationLabel ?? null, diff --git a/src/utilities/campaignDashboardViewModels.ts b/src/utilities/campaignDashboardViewModels.ts index b3fb3a23..ef8cd965 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 { formatNucleusTerritoryLabel } from '@/utilities/nucleusUi' export type CoordinatorSummary = { id: number @@ -11,9 +12,9 @@ export type DashboardNucleusRecord = { id: number name: string slug: string - region: ElectoralNucleus['region'] - city: string | null - neighborhood: string | null + regions: string[] + cities: string[] + neighborhoods: string[] locality: string | null organizationKind: ElectoralNucleus['organizationKind'] organizationLabel: string | null @@ -105,8 +106,8 @@ const percentage = (part: number, total: number): number => const isOverdue = (lastUpdateAt: string | null, now: Date): boolean => !lastUpdateAt || now.getTime() - new Date(lastUpdateAt).getTime() > 7 * 24 * 60 * 60 * 1000 -const territoryLabel = ({ neighborhood, locality, city, region }: DashboardNucleusRecord): string => - [neighborhood, locality, city, region].filter(Boolean).join(' · ') || 'Território não informado' +const territoryLabel = (nucleus: DashboardNucleusRecord): string => + formatNucleusTerritoryLabel(nucleus) || 'Território não informado' const organizationLabel = ({ organizationKind, diff --git a/src/utilities/nucleusFormData.ts b/src/utilities/nucleusFormData.ts index 253404cd..fc8e896a 100644 --- a/src/utilities/nucleusFormData.ts +++ b/src/utilities/nucleusFormData.ts @@ -4,7 +4,7 @@ import { bahiaIdentityTerritories, type BahiaIdentityTerritory, isBahiaMunicipality, - validateBahiaTerritoryPair, + territoriesForCities, } from '@/lib/bahiaTerritories' import { organizationKinds, @@ -16,6 +16,7 @@ import { checkboxFormValue, FormDataBoundaryError, optionalFormText, + repeatedFormTexts, repeatedRelationshipFormValues, requiredRelationshipFormValue, } from '@/lib/formData' @@ -27,15 +28,19 @@ const parseZoneNumbers = (value: string | undefined): Array<{ zoneNumber: number const parseSharedNucleusFormData = (formData: FormData) => { const organizationKind = optionalFormText(formData, 'organizationKind') const sectorKind = optionalFormText(formData, 'sectorKind') - const region = optionalFormText(formData, 'region') - const city = optionalFormText(formData, 'city') + 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 partnerName = optionalFormText(formData, 'partnerName') return { name: optionalFormText(formData, 'name') ?? '', - region: region as BahiaIdentityTerritory | undefined, - city, - neighborhood: city ? optionalFormText(formData, 'neighborhood') : undefined, + regions, + cities, + neighborhoods, locality: optionalFormText(formData, 'locality'), territoryNotes: optionalFormText(formData, 'territoryNotes'), organizationKind: organizationKind as (typeof organizationKinds)[number], @@ -54,24 +59,40 @@ const parseSharedNucleusFormData = (formData: FormData) => { } const validateTerritoryFormData = ( - territory: { region?: string; city?: string; locality?: string }, + territory: { + regions: string[] + cities: string[] + neighborhoods: string[] + locality?: string + }, { requireGeography }: { requireGeography: boolean }, ) => { - if (territory.region && !bahiaIdentityTerritories.includes(territory.region as never)) { - throw new FormDataBoundaryError('region', 'Território de identidade inválido.') + for (const region of territory.regions) { + if (!bahiaIdentityTerritories.includes(region as never)) { + throw new FormDataBoundaryError('regions', 'Território de identidade inválido.') + } } - if (territory.city && !isBahiaMunicipality(territory.city)) { - throw new FormDataBoundaryError('city', 'Município inválido.') + for (const city of territory.cities) { + if (!isBahiaMunicipality(city)) { + throw new FormDataBoundaryError('cities', 'Município inválido.') + } } - if (!validateBahiaTerritoryPair(territory.region as BahiaIdentityTerritory, territory.city)) { + if (territory.neighborhoods.length > 0 && territory.cities.length !== 1) { throw new FormDataBoundaryError( - 'city', - 'O município não pertence ao território de identidade selecionado.', + '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.region && !territory.city && !territory.locality) { + if ( + requireGeography && + territory.regions.length === 0 && + territory.cities.length === 0 && + !territory.locality + ) { throw new FormDataBoundaryError( - 'city', + 'cities', 'Informe o território de identidade, município ou localidade do núcleo.', ) } @@ -96,9 +117,9 @@ export const parseNucleusUpdateFormData = (formData: FormData): NucleusUpdateInp return { ...shared, id: requiredRelationshipFormValue(formData, 'id'), - region: shared.region ?? null, - city: shared.city ?? null, - neighborhood: shared.neighborhood ?? null, + regions: shared.regions, + cities: shared.cities, + neighborhoods: shared.neighborhoods, locality: shared.locality ?? null, territoryNotes: shared.territoryNotes ?? null, organizationLabel: shared.organizationLabel ?? null, diff --git a/src/utilities/nucleusPageData.ts b/src/utilities/nucleusPageData.ts index 937b3eed..cd4cf8d6 100644 --- a/src/utilities/nucleusPageData.ts +++ b/src/utilities/nucleusPageData.ts @@ -36,9 +36,9 @@ const nucleusContextSelect = { name: true, slug: true, status: true, - region: true, - city: true, - neighborhood: true, + regions: true, + cities: true, + neighborhoods: true, locality: true, organizationKind: true, organizationLabel: true, diff --git a/src/utilities/nucleusUi.ts b/src/utilities/nucleusUi.ts index 2d41caea..cf3e376b 100644 --- a/src/utilities/nucleusUi.ts +++ b/src/utilities/nucleusUi.ts @@ -33,6 +33,26 @@ export const sectorKindLabels: Record + [ + (neighborhoods ?? []).join(', '), + locality, + (cities ?? []).join(', '), + (regions ?? []).join(', '), + ] + .filter(Boolean) + .join(' · ') + export type NucleusListState = { page: number q?: string @@ -130,8 +150,8 @@ export const buildNucleusListWhere = (state: NucleusListState): Where => { } filters.push({ or: searchFilters }) } - if (state.region) filters.push({ region: { equals: state.region } }) - if (state.city) filters.push({ city: { equals: state.city } }) + if (state.region) filters.push({ regions: { equals: state.region } }) + if (state.city) filters.push({ cities: { equals: state.city } }) if (state.tseZone) { filters.push({ 'tseZones.zoneNumber': { equals: state.tseZone } }) } diff --git a/src/utilities/nucleusViewModels.ts b/src/utilities/nucleusViewModels.ts index 4cdf75c9..5d65f0d3 100644 --- a/src/utilities/nucleusViewModels.ts +++ b/src/utilities/nucleusViewModels.ts @@ -1,12 +1,15 @@ import type { CampaignUser, ElectoralNucleus } 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 nucleusFormSelect = { name: true, slug: true, - region: true, - city: true, - neighborhood: true, + regions: true, + cities: true, + neighborhoods: true, locality: true, territoryNotes: true, organizationKind: true, @@ -20,9 +23,9 @@ export const nucleusListSelect = { name: true, slug: true, coordinators: true, - region: true, - city: true, - neighborhood: true, + regions: true, + cities: true, + neighborhoods: true, locality: true, organizationKind: true, organizationLabel: true, @@ -37,9 +40,9 @@ export type NucleusListViewModel = { name: string slug: string coordinators: Array<{ id: number; name: string }> - region: ElectoralNucleus['region'] - city: string | null - neighborhood: string | null + regions: string[] + cities: string[] + neighborhoods: string[] locality: string | null organizationKind: ElectoralNucleus['organizationKind'] organizationLabel: string | null @@ -53,9 +56,9 @@ export const nucleusStaffDetailSelect = { name: true, slug: true, status: true, - region: true, - city: true, - neighborhood: true, + regions: true, + cities: true, + neighborhoods: true, locality: true, territoryNotes: true, organizationKind: true, @@ -91,9 +94,9 @@ export const nucleusLeadershipDetailSelect = { name: true, slug: true, status: true, - region: true, - city: true, - neighborhood: true, + regions: true, + cities: true, + neighborhoods: true, locality: true, organizationKind: true, organizationLabel: true, @@ -105,9 +108,9 @@ export type NucleusFormViewModel = { id: number name: string slug: string - region: ElectoralNucleus['region'] - city: string | null - neighborhood: string | null + regions: string[] + cities: string[] + neighborhoods: string[] locality: string | null territoryNotes: string | null organizationKind: ElectoralNucleus['organizationKind'] @@ -124,9 +127,9 @@ export type NucleusFormViewModel = { export type StaffNucleusTabsViewModel = { kind: 'staff' - region: ElectoralNucleus['region'] - city: string | null - neighborhood: string | null + regions: string[] + cities: string[] + neighborhoods: string[] locality: string | null territoryNotes: string | null tseZones: number[] @@ -150,9 +153,9 @@ export type StaffNucleusTabsViewModel = { export type LeadershipNucleusTabsViewModel = { kind: 'leadership' - region: ElectoralNucleus['region'] - city: string | null - neighborhood: string | null + regions: string[] + cities: string[] + neighborhoods: string[] locality: string | null tseZones: number[] } @@ -164,9 +167,9 @@ type NucleusDetailBaseViewModel = { name: string slug: string status: ElectoralNucleus['status'] - region: ElectoralNucleus['region'] - city: string | null - neighborhood: string | null + regions: string[] + cities: string[] + neighborhoods: string[] locality: string | null organizationKind: ElectoralNucleus['organizationKind'] organizationLabel: string | null @@ -206,9 +209,9 @@ export const toNucleusListViewModel = (nucleus: ElectoralNucleus): NucleusListVi isPopulatedRelationship(coordinator), ) .map(({ id, name }) => ({ id, name })) ?? [], - region: nucleus.region ?? null, - city: nucleus.city ?? null, - neighborhood: nucleus.neighborhood ?? null, + regions: asStringArray(nucleus.regions), + cities: asStringArray(nucleus.cities), + neighborhoods: asStringArray(nucleus.neighborhoods), locality: nucleus.locality ?? null, organizationKind: nucleus.organizationKind, organizationLabel: nucleus.organizationLabel ?? null, @@ -222,9 +225,9 @@ export const toNucleusFormViewModel = (nucleus: ElectoralNucleus): NucleusFormVi id: nucleus.id, name: nucleus.name, slug: nucleus.slug, - region: nucleus.region ?? null, - city: nucleus.city ?? null, - neighborhood: nucleus.neighborhood ?? null, + regions: asStringArray(nucleus.regions), + cities: asStringArray(nucleus.cities), + neighborhoods: asStringArray(nucleus.neighborhoods), locality: nucleus.locality ?? null, territoryNotes: nucleus.territoryNotes ?? null, organizationKind: nucleus.organizationKind, @@ -246,9 +249,9 @@ const toNucleusDetailBaseViewModel = (nucleus: ElectoralNucleus): NucleusDetailB name: nucleus.name, slug: nucleus.slug, status: nucleus.status, - region: nucleus.region ?? null, - city: nucleus.city ?? null, - neighborhood: nucleus.neighborhood ?? null, + regions: asStringArray(nucleus.regions), + cities: asStringArray(nucleus.cities), + neighborhoods: asStringArray(nucleus.neighborhoods), locality: nucleus.locality ?? null, organizationKind: nucleus.organizationKind, organizationLabel: nucleus.organizationLabel ?? null, @@ -295,9 +298,9 @@ export const toStaffNucleusTabsViewModel = ( nucleus: ElectoralNucleus, ): StaffNucleusTabsViewModel => ({ kind: 'staff', - region: nucleus.region ?? null, - city: nucleus.city ?? null, - neighborhood: nucleus.neighborhood ?? null, + regions: asStringArray(nucleus.regions), + cities: asStringArray(nucleus.cities), + neighborhoods: asStringArray(nucleus.neighborhoods), locality: nucleus.locality ?? null, territoryNotes: nucleus.territoryNotes ?? null, tseZones: nucleus.tseZones?.map(({ zoneNumber }) => zoneNumber) ?? [], @@ -328,9 +331,9 @@ export const toLeadershipNucleusTabsViewModel = ( nucleus: ElectoralNucleus, ): LeadershipNucleusTabsViewModel => ({ kind: 'leadership', - region: nucleus.region ?? null, - city: nucleus.city ?? null, - neighborhood: nucleus.neighborhood ?? null, + regions: asStringArray(nucleus.regions), + cities: asStringArray(nucleus.cities), + neighborhoods: asStringArray(nucleus.neighborhoods), locality: nucleus.locality ?? null, tseZones: nucleus.tseZones?.map(({ zoneNumber }) => zoneNumber) ?? [], }) diff --git a/tests/e2e/campaign-auth-rbac.e2e.spec.ts b/tests/e2e/campaign-auth-rbac.e2e.spec.ts index b80e3340..0d284f17 100644 --- a/tests/e2e/campaign-auth-rbac.e2e.spec.ts +++ b/tests/e2e/campaign-auth-rbac.e2e.spec.ts @@ -75,7 +75,7 @@ test.describe('Campaign authentication and role scope', () => { collection: 'electoralNucleus', data: { name: foreignName, - city: 'Salvador', + cities: ['Salvador'], organizationKind: 'territorial', } as never, depth: 0, @@ -133,7 +133,7 @@ test.describe('Campaign authentication and role scope', () => { collection: 'electoralNucleus', data: { name: nucleusName, - city: 'Salvador', + cities: ['Salvador'], organizationKind: 'territorial', } as never, depth: 0, @@ -143,7 +143,7 @@ test.describe('Campaign authentication and role scope', () => { collection: 'electoralNucleus', data: { name: campaign.fixtures.value('Núcleo fora do escopo'), - city: 'Salvador', + cities: ['Salvador'], organizationKind: 'territorial', } as never, depth: 0, diff --git a/tests/e2e/campaign-composition-accessibility.e2e.spec.ts b/tests/e2e/campaign-composition-accessibility.e2e.spec.ts index 9a2dde6a..cfcd0c90 100644 --- a/tests/e2e/campaign-composition-accessibility.e2e.spec.ts +++ b/tests/e2e/campaign-composition-accessibility.e2e.spec.ts @@ -27,7 +27,7 @@ test.describe('Campaign composition and accessibility', () => { collection: 'electoralNucleus', data: { name: campaign.fixtures.value('Núcleo composição'), - city: 'Salvador', + cities: ['Salvador'], organizationKind: 'territorial', } as never, depth: 0, diff --git a/tests/e2e/campaign-estimates-updates.e2e.spec.ts b/tests/e2e/campaign-estimates-updates.e2e.spec.ts index c3db1557..33be72b9 100644 --- a/tests/e2e/campaign-estimates-updates.e2e.spec.ts +++ b/tests/e2e/campaign-estimates-updates.e2e.spec.ts @@ -27,7 +27,7 @@ test.describe('Campaign estimates and field updates', () => { collection: 'electoralNucleus', data: { name: campaign.fixtures.value('Núcleo estimativa'), - city: 'Salvador', + cities: ['Salvador'], coordinators: [staff.id], organizationKind: 'territorial', } as never, @@ -86,7 +86,7 @@ test.describe('Campaign estimates and field updates', () => { collection: 'electoralNucleus', data: { name: campaign.fixtures.value('Núcleo atualização'), - city: 'Salvador', + cities: ['Salvador'], coordinators: [staff.id], organizationKind: 'territorial', } as never, diff --git a/tests/e2e/campaign-leadership-invite.e2e.spec.ts b/tests/e2e/campaign-leadership-invite.e2e.spec.ts index a9b8c55e..b274d074 100644 --- a/tests/e2e/campaign-leadership-invite.e2e.spec.ts +++ b/tests/e2e/campaign-leadership-invite.e2e.spec.ts @@ -26,7 +26,7 @@ test.describe('Campaign leadership and invite journeys', () => { collection: 'electoralNucleus', data: { name: campaign.fixtures.value('Núcleo liderança'), - region: 'Irecê', + regions: ['Irecê'], organizationKind: 'territorial', } as never, depth: 0, @@ -95,7 +95,7 @@ test.describe('Campaign leadership and invite journeys', () => { collection: 'electoralNucleus', data: { name: campaign.fixtures.value('Núcleo painel'), - city: 'Salvador', + cities: ['Salvador'], organizationKind: 'territorial', } as never, depth: 0, @@ -252,7 +252,7 @@ test.describe('Campaign leadership and invite journeys', () => { collection: 'electoralNucleus', data: { name: campaign.fixtures.value('Núcleo convite'), - city: 'Salvador', + cities: ['Salvador'], organizationKind: 'territorial', } as never, depth: 0, diff --git a/tests/e2e/campaign-nuclei.e2e.spec.ts b/tests/e2e/campaign-nuclei.e2e.spec.ts index 0825ffeb..5ce3533b 100644 --- a/tests/e2e/campaign-nuclei.e2e.spec.ts +++ b/tests/e2e/campaign-nuclei.e2e.spec.ts @@ -27,7 +27,7 @@ test.describe('Campaign nucleus journeys', () => { await campaign.login(page, generalEmail, password) await page.goto(`${campaign.baseURL}/campanha/nucleos/novo`) await page.getByLabel('Nome do núcleo *').fill(nucleusName) - await page.getByLabel('Território de identidade').fill('Irecê') + await page.getByLabel('Territórios de identidade').fill('Irecê') await page.getByRole('option', { name: 'Irecê', exact: true }).click() const zoneInput = page.getByLabel('Adicionar Zona TSE') await zoneInput.pressSequentially('58,12 58') @@ -92,7 +92,7 @@ test.describe('Campaign nucleus journeys', () => { collection: 'electoralNucleus', data: { name: campaign.fixtures.value('Núcleo designação'), - city: 'Salvador', + cities: ['Salvador'], coordinators: [general.id], organizationKind: 'territorial', } as never, @@ -145,7 +145,7 @@ test.describe('Campaign nucleus journeys', () => { collection: 'electoralNucleus', data: { name: nucleusName, - city: 'Salvador', + cities: ['Salvador'], organizationKind: 'territorial', } as never, depth: 0, @@ -199,7 +199,7 @@ test.describe('Campaign nucleus journeys', () => { collection: 'electoralNucleus', data: { name: campaign.fixtures.value('Núcleo responsivo'), - city: 'Salvador', + cities: ['Salvador'], organizationKind: 'territorial', } as never, depth: 0, diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index e2e04a8c..1c6bb6fa 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -269,7 +269,7 @@ export const test = base.extend({ collection: 'electoralNucleus', data: { name: sentinelName, - city: 'Salvador', + cities: ['Salvador'], organizationKind: 'territorial', } as never, depth: 0, diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index 4fd52732..64ecc5be 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -354,7 +354,7 @@ export class CampaignFixtures { name, slug: input.slug ?? this.value('nucleo'), status: input.status ?? 'ativo', - city: input.city === undefined ? 'Salvador' : input.city, + cities: input.cities === undefined ? ['Salvador'] : input.cities, organizationKind: input.organizationKind ?? 'territorial', } const nucleus = await this.rootPayload.create({ diff --git a/tests/int/bahiaTerritories.int.spec.ts b/tests/int/bahiaTerritories.int.spec.ts index 1869f1d8..459ba4b7 100644 --- a/tests/int/bahiaTerritories.int.spec.ts +++ b/tests/int/bahiaTerritories.int.spec.ts @@ -92,19 +92,19 @@ describe('Bahia identity territory mapping', () => { expect(validateBahiaTerritoryPair('Chapada Diamantina', 'Salvador')).toBe(false) }) - it('rejects mismatched territory and municipality pairs on the server', () => { - const mismatched = { - name: 'Núcleo incompatível', - region: 'Chapada Diamantina', - city: 'Salvador', + it('derives the region from cities, overriding a mismatched manually provided region', () => { + const withMismatchedRegion = { + name: 'Núcleo com território derivado', + regions: ['Chapada Diamantina'], + cities: ['Salvador'], organizationKind: 'territorial', } as const - expect(() => nucleusCreateSchema.parse(mismatched)).toThrow( - 'município não pertence ao território', - ) - expect(() => nucleusUpdateSchema.parse({ id: 1, ...mismatched })).toThrow( - 'município não pertence ao território', - ) + expect(nucleusCreateSchema.parse(withMismatchedRegion).regions).toEqual([ + 'Metropolitano de Salvador', + ]) + expect( + nucleusUpdateSchema.parse({ id: 1, ...withMismatchedRegion }).regions, + ).toEqual(['Metropolitano de Salvador']) }) }) diff --git a/tests/int/campaignAuth.int.spec.ts b/tests/int/campaignAuth.int.spec.ts index e8d1ce1a..4a7808f6 100644 --- a/tests/int/campaignAuth.int.spec.ts +++ b/tests/int/campaignAuth.int.spec.ts @@ -324,7 +324,7 @@ describe('campaign authentication foundation', () => { slug: slugify(nucleusName), status: nucleusStatus, coordinators: [target.id], - city: 'Salvador', + cities: ['Salvador'], organizationKind: 'territorial', }, depth: 0, diff --git a/tests/int/campaignInviteUi.int.spec.ts b/tests/int/campaignInviteUi.int.spec.ts index ad4ae598..412e0f91 100644 --- a/tests/int/campaignInviteUi.int.spec.ts +++ b/tests/int/campaignInviteUi.int.spec.ts @@ -125,9 +125,7 @@ const createPublicInviteScenario = async ( collection: 'electoralNucleus', data: { name: campaignFixtures().value('Núcleo convite UI'), - territoryKind: 'municipio', - state: 'BA', - city: 'Salvador', + cities: ['Salvador'], organizationKind: 'territorial', } as never, depth: 0, diff --git a/tests/int/campaignLeadershipActions.int.spec.ts b/tests/int/campaignLeadershipActions.int.spec.ts index 4e58925e..213a05a1 100644 --- a/tests/int/campaignLeadershipActions.int.spec.ts +++ b/tests/int/campaignLeadershipActions.int.spec.ts @@ -107,7 +107,7 @@ describe('campaign leadership exported form actions', () => { collection: 'electoralNucleus', data: { name: campaignFixtures().value('Núcleo regional action'), - region: 'Irecê', + regions: ['Irecê'], organizationKind: 'territorial', } as never, depth: 0, @@ -158,7 +158,7 @@ describe('campaign leadership exported form actions', () => { collection: 'electoralNucleus', data: { name: campaignFixtures().value('Núcleo ação mínima'), - region: 'Irecê', + regions: ['Irecê'], organizationKind: 'territorial', } as never, depth: 0, diff --git a/tests/int/campaignNucleus.int.spec.ts b/tests/int/campaignNucleus.int.spec.ts index 2f22a1e0..a628d0a3 100644 --- a/tests/int/campaignNucleus.int.spec.ts +++ b/tests/int/campaignNucleus.int.spec.ts @@ -35,7 +35,7 @@ const validNucleusInput = { get name() { return campaignFixtures().value('Núcleo Chapada') }, - region: 'Chapada Diamantina' as const, + regions: ['Chapada Diamantina'], locality: 'Chapada Diamantina', organizationKind: 'territorial' as const, tseZones: [{ zoneNumber: 58, label: '58ª ZE — Seabra' }], @@ -59,10 +59,10 @@ describe('electoral nucleus domain', () => { ).toBe(417) }) - it('requires a region, city, or locality for every nucleus', () => { + it('requires regions, cities, or a locality for every nucleus', () => { const result = nucleusCreateSchema.safeParse({ ...validNucleusInput, - region: undefined, + regions: undefined, locality: undefined, }) @@ -79,12 +79,13 @@ describe('electoral nucleus domain', () => { expect( nucleusUpdateSchema.parse({ id: 1, - city: ' ', + cities: [], territoryNotes: ' ', }), ).toEqual({ id: 1, - city: null, + cities: [], + regions: [], territoryNotes: null, }) expect(nucleusUpdateSchema.parse({ id: 1 })).toEqual({ id: 1 }) @@ -136,7 +137,7 @@ describe('electoral nucleus domain', () => { sectorKind: 'sindical', }) - expect(result.region).toBe('Chapada Diamantina') + expect(result.regions).toEqual(['Chapada Diamantina']) expect(result.organizationKind).toBe('sindicato') expect(result.sectorKind).toBe('sindical') }) @@ -202,26 +203,26 @@ describe('electoral nucleus domain', () => { expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1) }) - it('validates Bahia regions, cities, territory pairs, and neighborhood dependencies', async () => { + it('validates Bahia regions, cities, and neighborhood dependencies', async () => { expect(() => nucleusCreateSchema.parse({ ...validNucleusInput, - region: 'Região inventada', + regions: ['Região inventada'], }), ).toThrow() expect(() => nucleusCreateSchema.parse({ ...validNucleusInput, - city: 'Município inventado', + cities: ['Município inventado'], }), ).toThrow('município válido') expect(() => nucleusCreateSchema.parse({ ...validNucleusInput, - city: undefined, - neighborhood: 'Centro', + cities: undefined, + neighborhoods: ['Centro'], }), ).toThrow('município antes do bairro') @@ -230,86 +231,122 @@ describe('electoral nucleus domain', () => { collection: 'electoralNucleus', data: { ...validNucleusInput, - city: 'Salvador', + cities: ['Município inventado'], } as never, depth: 0, }), - ).rejects.toThrow('município não pertence ao território') + ).rejects.toThrow('município válido') + }) + it('derives regions from multiple cities across territories, overriding a manual region', async () => { const nucleus = await payload.create({ collection: 'electoralNucleus', data: { ...validNucleusInput, - city: 'Seabra', + name: campaignFixtures().value('Núcleo multi-território'), + regions: ['Irecê'], + cities: ['Seabra', 'Salvador'], } as never, depth: 0, }) - await expect( - payload.update({ - collection: 'electoralNucleus', - id: nucleus.id, - data: { city: 'Salvador' }, - depth: 0, - }), - ).rejects.toThrow('município não pertence ao território') + + expect(nucleus.cities).toEqual(['Seabra', 'Salvador']) + expect(nucleus.regions).toEqual(['Chapada Diamantina', 'Metropolitano de Salvador']) + + const updated = await payload.update({ + collection: 'electoralNucleus', + id: nucleus.id, + data: { cities: ['Salvador'] }, + depth: 0, + }) + expect(updated.cities).toEqual(['Salvador']) + expect(updated.regions).toEqual(['Metropolitano de Salvador']) }) - it('allows a neighborhood-only PATCH to preserve the existing city', async () => { + it('allows multiple neighborhoods only when exactly one city is set', async () => { const general = await campaignFixtures().createCampaignUser('geral') const nucleus = await createElectoralNucleus(payload, general, { ...validNucleusInput, - name: campaignFixtures().value('Núcleo com bairro atualizado'), - city: 'Seabra', + name: campaignFixtures().value('Núcleo com bairros múltiplos'), + cities: ['Seabra'], + neighborhoods: ['Centro', 'Bairro Novo'], }) - const updated = await updateElectoralNucleus(payload, general, { - id: nucleus.id, - neighborhood: 'Centro', - }) - - expect(updated.city).toBe('Seabra') - expect(updated.neighborhood).toBe('Centro') + expect(nucleus.cities).toEqual(['Seabra']) + expect(nucleus.neighborhoods).toEqual(['Centro', 'Bairro Novo']) }) - it('rejects explicitly clearing city while providing a neighborhood', () => { + it('rejects neighborhoods when more than one city is selected', async () => { + expect(() => + nucleusCreateSchema.parse({ + ...validNucleusInput, + cities: ['Seabra', 'Salvador'], + neighborhoods: ['Centro'], + }), + ).toThrow('exatamente um município') + + await expect( + payload.create({ + collection: 'electoralNucleus', + data: { + ...validNucleusInput, + name: campaignFixtures().value('Núcleo com bairro em dois municípios'), + cities: ['Seabra', 'Salvador'], + neighborhoods: ['Centro'], + } as never, + depth: 0, + }), + ).rejects.toThrow('exatamente um município') + }) + + it('requires resending cities together with neighborhoods on a PATCH', () => { expect(() => nucleusUpdateSchema.parse({ id: 1, - city: null, - neighborhood: 'Centro', + neighborhoods: ['Centro'], }), ).toThrow('município antes do bairro') }) - it('rejects clearing city when an omitted existing neighborhood remains', async () => { + it('rejects explicitly clearing cities while providing a neighborhood', () => { + expect(() => + nucleusUpdateSchema.parse({ + id: 1, + cities: [], + neighborhoods: ['Centro'], + }), + ).toThrow('município antes do bairro') + }) + + it('rejects clearing cities when an omitted existing neighborhood remains', async () => { const general = await campaignFixtures().createCampaignUser('geral') const nucleus = await createElectoralNucleus(payload, general, { ...validNucleusInput, name: campaignFixtures().value('Núcleo com bairro preservado'), - city: 'Seabra', - neighborhood: 'Centro', + cities: ['Seabra'], + neighborhoods: ['Centro'], }) await expect( updateElectoralNucleus(payload, general, { id: nucleus.id, - city: null, + cities: null, }), ).rejects.toThrow('município antes do bairro') }) - it('allows the form to clear city and neighborhood when other geography remains', async () => { + it('allows the form to clear cities and neighborhoods when other geography remains', async () => { const general = await campaignFixtures().createCampaignUser('geral') const nucleus = await createElectoralNucleus(payload, general, { ...validNucleusInput, name: campaignFixtures().value('Núcleo com geografia pelo formulário'), - city: 'Seabra', - neighborhood: 'Centro', + cities: ['Seabra'], + neighborhoods: ['Centro'], }) const formData = new FormData() formData.set('id', String(nucleus.id)) formData.set('name', nucleus.name) - formData.set('region', 'Chapada Diamantina') + formData.append('regions', 'Chapada Diamantina') formData.set('locality', 'Zona rural') formData.set('organizationKind', 'territorial') @@ -319,9 +356,9 @@ describe('electoral nucleus domain', () => { parseNucleusUpdateFormData(formData), ) - expect(updated.city).toBeNull() - expect(updated.neighborhood).toBeNull() - expect(updated.region).toBe('Chapada Diamantina') + expect(updated.cities).toEqual([]) + expect(updated.neighborhoods).toEqual([]) + expect(updated.regions).toEqual(['Chapada Diamantina']) expect(updated.locality).toBe('Zona rural') }) @@ -370,7 +407,7 @@ describe('electoral nucleus domain', () => { collection: 'electoralNucleus', data: { ...validNucleusInput, - region: undefined, + regions: undefined, locality: ' ', } as never, }), @@ -397,8 +434,8 @@ describe('electoral nucleus domain', () => { collection: 'electoralNucleus', id: nucleus.id, data: { - city: null, - region: null, + cities: [], + regions: [], locality: null, }, }), @@ -409,16 +446,16 @@ describe('electoral nucleus domain', () => { data: { ...validNucleusInput, name: campaignFixtures().value('Núcleo com geografia normalizada'), - region: 'Chapada Diamantina', - city: 'Seabra', + regions: ['Chapada Diamantina'], + cities: ['Seabra'], locality: ' Chapada Diamantina ', - neighborhood: ' Centro ', + neighborhoods: [' Centro '], tseZones: [{ zoneNumber: 64 }], } as never, }) - expect(trimmed.region).toBe('Chapada Diamantina') - expect(trimmed.neighborhood).toBe('Centro') + expect(trimmed.regions).toEqual(['Chapada Diamantina']) + expect(trimmed.neighborhoods).toEqual(['Centro']) expect(trimmed.locality).toBe('Chapada Diamantina') }) @@ -463,7 +500,7 @@ describe('electoral nucleus domain', () => { expect(nucleus.status).toBe('ativo') expect(nucleus.createdBy).toBe(general.id) - expect(nucleus.region).toBe('Chapada Diamantina') + expect(nucleus.regions).toEqual(['Chapada Diamantina']) expect(nucleus.coordinators).toContain(general.id) }) @@ -533,7 +570,7 @@ describe('electoral nucleus domain', () => { const coordinator = await campaignFixtures().createCampaignUser('coordenador') const formData = new FormData() formData.set('name', campaignFixtures().value('Núcleo via formulário')) - formData.set('city', 'Salvador') + formData.append('cities', 'Salvador') formData.set('organizationKind', 'territorial') const created = await createElectoralNucleus( @@ -834,13 +871,47 @@ describe('electoral nucleus domain', () => { ).toBe(true) }) + it('filters nuclei by regions and cities using Payload equals queries', async () => { + const general = await campaignFixtures().createCampaignUser('geral') + const chapada = await createElectoralNucleus(payload, general, { + ...validNucleusInput, + name: campaignFixtures().value('Núcleo Chapada equals'), + cities: ['Seabra'], + }) + const salvador = await createElectoralNucleus(payload, general, { + ...validNucleusInput, + name: campaignFixtures().value('Núcleo Salvador equals'), + cities: ['Salvador'], + }) + + const byRegion = await payload.find({ + collection: 'electoralNucleus', + depth: 0, + where: { regions: { equals: 'Chapada Diamantina' } }, + user: general, + overrideAccess: false, + }) + expect(byRegion.docs.map(({ id }) => id)).toContain(chapada.id) + expect(byRegion.docs.map(({ id }) => id)).not.toContain(salvador.id) + + const byCity = await payload.find({ + collection: 'electoralNucleus', + depth: 0, + where: { cities: { equals: 'Salvador' } }, + user: general, + overrideAccess: false, + }) + expect(byCity.docs.map(({ id }) => id)).toContain(salvador.id) + expect(byCity.docs.map(({ id }) => id)).not.toContain(chapada.id) + }) + it('explicitly clears editable optional nucleus fields', async () => { const general = await campaignFixtures().createCampaignUser('geral') const coordinator = await campaignFixtures().createCampaignUser('coordenador') const nucleus = await createElectoralNucleus(payload, general, { ...validNucleusInput, coordinators: [coordinator.id], - city: 'Seabra', + cities: ['Seabra'], locality: 'Centro', organizationLabel: 'Associação local', sectorKind: 'rural', @@ -857,7 +928,7 @@ describe('electoral nucleus domain', () => { id: nucleus.id, coordinators: [], tseZones: [], - city: null, + cities: null, locality: 'Centro', sectorKind: null, organizationLabel: null, @@ -871,7 +942,8 @@ describe('electoral nucleus domain', () => { expect(cleared.coordinators).toEqual([coordinator.id]) expect(cleared.tseZones).toEqual([]) - expect(cleared.city).toBeNull() + expect(cleared.cities).toEqual([]) + expect(cleared.regions).toEqual([]) expect(cleared.locality).toBe('Centro') expect(cleared.sectorKind).toBeNull() expect(cleared.organizationLabel).toBeNull() @@ -884,10 +956,10 @@ describe('electoral nucleus domain', () => { const clearedLocality = await updateElectoralNucleus(payload, general, { id: nucleus.id, - city: 'Seabra', + cities: ['Seabra'], locality: null, }) - expect(clearedLocality.city).toBe('Seabra') + expect(clearedLocality.cities).toEqual(['Seabra']) expect(clearedLocality.locality).toBeNull() }) diff --git a/tests/int/campaignNucleusDetailContext.int.spec.ts b/tests/int/campaignNucleusDetailContext.int.spec.ts index bee24f71..979240a0 100644 --- a/tests/int/campaignNucleusDetailContext.int.spec.ts +++ b/tests/int/campaignNucleusDetailContext.int.spec.ts @@ -29,8 +29,8 @@ describe('campaign accessible nucleus detail context', () => { }) const nucleus = await campaignFixtures().createNucleus({ name: campaignFixtures().value('Núcleo contexto real'), - region: 'Metropolitano de Salvador', - city: 'Salvador', + regions: ['Metropolitano de Salvador'], + cities: ['Salvador'], organizationKind: 'territorial', }) diff --git a/tests/int/campaignNucleusUpdatePageData.int.spec.ts b/tests/int/campaignNucleusUpdatePageData.int.spec.ts index c74c2d92..5595fa6d 100644 --- a/tests/int/campaignNucleusUpdatePageData.int.spec.ts +++ b/tests/int/campaignNucleusUpdatePageData.int.spec.ts @@ -73,8 +73,8 @@ describe('campaign nucleus update page data queries', () => { }) const nucleus = await campaignFixtures().createNucleus({ name: campaignFixtures().value('Núcleo feed real'), - region: 'Metropolitano de Salvador', - city: 'Salvador', + regions: ['Metropolitano de Salvador'], + cities: ['Salvador'], organizationKind: 'territorial', }) for (let index = 0; index < 4; index += 1) { @@ -121,8 +121,8 @@ describe('campaign nucleus update page data queries', () => { }) const nucleus = await campaignFixtures().createNucleus({ name: campaignFixtures().value('Núcleo preview real'), - region: 'Metropolitano de Salvador', - city: 'Salvador', + regions: ['Metropolitano de Salvador'], + cities: ['Salvador'], organizationKind: 'territorial', }) for (let index = 0; index < 4; index += 1) { diff --git a/tests/int/campaignPrimaryContactPageData.int.spec.ts b/tests/int/campaignPrimaryContactPageData.int.spec.ts index d1bb7b5d..10418a06 100644 --- a/tests/int/campaignPrimaryContactPageData.int.spec.ts +++ b/tests/int/campaignPrimaryContactPageData.int.spec.ts @@ -43,7 +43,7 @@ describe('campaign primary contact page data', () => { collection: 'electoralNucleus', data: { name: campaignFixtures().value('Núcleo contato principal'), - region: 'Chapada Diamantina', + regions: ['Chapada Diamantina'], locality: 'Chapada Diamantina', organizationKind: 'territorial', } as never, @@ -143,7 +143,7 @@ describe('campaign primary contact page data', () => { data: { name: campaignFixtures().value('Núcleo contato escopo'), coordinators: [coordinator.id], - region: 'Chapada Diamantina', + regions: ['Chapada Diamantina'], locality: 'Chapada Diamantina', organizationKind: 'territorial', } as never, diff --git a/tests/unit/campaignNucleusFilters.unit.spec.ts b/tests/unit/campaignNucleusFilters.unit.spec.ts index da367aec..199b2807 100644 --- a/tests/unit/campaignNucleusFilters.unit.spec.ts +++ b/tests/unit/campaignNucleusFilters.unit.spec.ts @@ -287,8 +287,8 @@ describe('campaign nucleus automatic filters', () => { { or: [{ name: { contains: '58' } }, { 'tseZones.zoneNumber': { equals: 58 } }], }, - { region: { equals: 'Chapada Diamantina' } }, - { city: { equals: 'Seabra' } }, + { regions: { equals: 'Chapada Diamantina' } }, + { cities: { equals: 'Seabra' } }, { 'tseZones.zoneNumber': { equals: 58 } }, { coordinators: { exists: true } }, { confirmedVoteEstimate: { exists: false } }, diff --git a/tests/unit/campaignNucleusIntelligenceUi.unit.spec.ts b/tests/unit/campaignNucleusIntelligenceUi.unit.spec.ts index 23548cc7..bf977a6c 100644 --- a/tests/unit/campaignNucleusIntelligenceUi.unit.spec.ts +++ b/tests/unit/campaignNucleusIntelligenceUi.unit.spec.ts @@ -269,9 +269,9 @@ describe('campaign nucleus intelligence UI', () => { slug: 'nucleo-seguro', status: 'ativo', coordinators: [99], - region: 'Chapada Diamantina', - city: 'Seabra', - neighborhood: 'Centro', + regions: ['Chapada Diamantina'], + cities: ['Seabra'], + neighborhoods: ['Centro'], organizationKind: 'territorial', sectorKind: 'rural', tseZones: [], @@ -315,9 +315,9 @@ describe('campaign nucleus intelligence UI', () => { collection: 'campaignUser', }, ], - region: 'Chapada Diamantina', - city: 'Seabra', - neighborhood: 'Centro', + regions: ['Chapada Diamantina'], + cities: ['Seabra'], + neighborhoods: ['Centro'], locality: 'Centro', territoryNotes: 'Estratégia territorial interna', organizationKind: 'territorial', @@ -376,9 +376,9 @@ describe('campaign nucleus intelligence UI', () => { name: 'Núcleo seguro', slug: 'nucleo-seguro', status: 'ativo', - region: 'Chapada Diamantina', - city: 'Seabra', - neighborhood: 'Centro', + regions: ['Chapada Diamantina'], + cities: ['Seabra'], + neighborhoods: ['Centro'], locality: 'Centro', organizationKind: 'territorial', organizationLabel: 'Organização local', @@ -386,9 +386,9 @@ describe('campaign nucleus intelligence UI', () => { confirmedVoteEstimate: 1200, tabs: { kind: 'leadership', - region: 'Chapada Diamantina', - city: 'Seabra', - neighborhood: 'Centro', + regions: ['Chapada Diamantina'], + cities: ['Seabra'], + neighborhoods: ['Centro'], locality: 'Centro', tseZones: [58], }, diff --git a/tests/unit/campaignNucleusSummaryLayout.unit.spec.ts b/tests/unit/campaignNucleusSummaryLayout.unit.spec.ts index 7abeab77..a5bf1fa2 100644 --- a/tests/unit/campaignNucleusSummaryLayout.unit.spec.ts +++ b/tests/unit/campaignNucleusSummaryLayout.unit.spec.ts @@ -77,6 +77,7 @@ vi.mock('@/utilities/nucleusUpdateUi', () => ({ })) vi.mock('@/utilities/nucleusUi', () => ({ organizationKindLabels: { territorial: 'Territorial' }, + formatNucleusTerritoryLabel: () => 'Metropolitano de Salvador', })) vi.mock('@/utilities/phone', () => ({ buildWhatsAppUrl: vi.fn() })) vi.mock('@/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions', () => ({ @@ -144,10 +145,10 @@ describe('campaign nucleus summary layout', () => { status: 'ativo', organizationKind: 'territorial', organizationLabel: null, - neighborhood: null, + neighborhoods: [], locality: null, - city: 'Salvador', - region: 'Metropolitano de Salvador', + cities: ['Salvador'], + regions: ['Metropolitano de Salvador'], tseZones: [], confirmedVoteEstimate: null, proposedVoteEstimate: null, diff --git a/tests/unit/campaignNucleusUi.unit.spec.ts b/tests/unit/campaignNucleusUi.unit.spec.ts index 3824d5c4..270c464c 100644 --- a/tests/unit/campaignNucleusUi.unit.spec.ts +++ b/tests/unit/campaignNucleusUi.unit.spec.ts @@ -78,28 +78,29 @@ describe('campaign nucleus UI contracts', () => { expect(matchesAtWordStart('São Félix', 'fel')).toBe(true) }) - it('selects a municipality with the keyboard and derives its territory', async () => { + it('adds a municipality as a chip via the strict combobox and derives its territory chip', async () => { render(createElement(NucleusTerritoryFields)) - const region = screen.getByLabelText('Território de identidade') as HTMLInputElement - const city = screen.getByLabelText('Município') as HTMLInputElement + const city = screen.getByLabelText('Municípios') as HTMLInputElement fireEvent.focus(city) fireEvent.change(city, { target: { value: 'Seab' } }) fireEvent.keyDown(city, { key: 'ArrowDown' }) fireEvent.keyDown(city, { key: 'Enter' }) - await waitFor(() => expect(city.value).toBe('Seabra')) - expect(region.value).toBe('Chapada Diamantina') + await waitFor(() => expect(city.value).toBe('')) + expect(screen.getByText('Seabra')).toBeTruthy() + expect(screen.getByText('Chapada Diamantina')).toBeTruthy() + expect(screen.queryByLabelText('Territórios de identidade')).toBeNull() }) - it('uses neutral editable lookup identifiers and submits only canonical hidden fields', () => { + it('uses neutral editable lookup identifiers and exposes only the canonical hidden fields', () => { const { container } = render( createElement(NucleusTerritoryFields, { - fieldErrors: { region: ['Território inválido.'], city: ['Município inválido.'] }, + fieldErrors: { regions: ['Território inválido.'], cities: ['Município inválido.'] }, }), ) - const region = screen.getByLabelText('Território de identidade') as HTMLInputElement - const city = screen.getByLabelText('Município') as HTMLInputElement + const region = screen.getByLabelText('Territórios de identidade') as HTMLInputElement + const city = screen.getByLabelText('Municípios') as HTMLInputElement for (const input of [region, city]) { expect(input.autocomplete).toBe('off') @@ -118,10 +119,11 @@ describe('campaign nucleus UI contracts', () => { expect(region.id).toBe('nucleus-lookup-a') expect(city.id).toBe('nucleus-lookup-b') expect(region.id).not.toBe(city.id) - expect(container.querySelectorAll('input[type="hidden"][name="region"]')).toHaveLength(1) - expect(container.querySelectorAll('input[type="hidden"][name="city"]')).toHaveLength(1) - expect(container.querySelectorAll('[name="region"]')).toHaveLength(1) - expect(container.querySelectorAll('[name="city"]')).toHaveLength(1) + expect(container.querySelectorAll('input[type="hidden"][name="cities"]')).toHaveLength(0) + expect(container.querySelectorAll('input[type="hidden"][name="regions"]')).toHaveLength(0) + expect(container.querySelectorAll('input[type="hidden"][name="neighborhoods"]')).toHaveLength( + 0, + ) expect(region.getAttribute('aria-describedby')).toBe('nucleus-lookup-a-error') expect(city.getAttribute('aria-describedby')).toBe('nucleus-lookup-b-error') expect(screen.getByText('Território inválido.').id).toBe('nucleus-lookup-a-error') @@ -130,7 +132,7 @@ describe('campaign nucleus UI contracts', () => { it('shows only word-start municipality matches', () => { render(createElement(NucleusTerritoryFields)) - const city = screen.getByLabelText('Município') + const city = screen.getByLabelText('Municípios') fireEvent.focus(city) fireEvent.change(city, { target: { value: 'sal' } }) @@ -141,10 +143,10 @@ describe('campaign nucleus UI contracts', () => { expect(screen.queryByRole('option', { name: 'Cruz das Almas' })).toBeNull() }) - it('clears invalid strict values on blur and exposes their errors', () => { + it('clears an invalid region or city draft on blur and exposes an error', () => { render(createElement(NucleusTerritoryFields)) - const region = screen.getByLabelText('Território de identidade') as HTMLInputElement - const city = screen.getByLabelText('Município') as HTMLInputElement + const region = screen.getByLabelText('Territórios de identidade') as HTMLInputElement + const city = screen.getByLabelText('Municípios') as HTMLInputElement fireEvent.change(region, { target: { value: 'Território inventado' } }) fireEvent.blur(region) @@ -159,45 +161,94 @@ describe('campaign nucleus UI contracts', () => { expect(screen.getByText('Selecione um município válido da Bahia.')).toBeTruthy() }) - it('filters municipalities to the selected territory', () => { + it('allows manually adding regions only until a city is selected, after which they become derived', async () => { render(createElement(NucleusTerritoryFields)) - const region = screen.getByLabelText('Território de identidade') - const city = screen.getByLabelText('Município') + const region = screen.getByLabelText('Territórios de identidade') as HTMLInputElement + const city = screen.getByLabelText('Municípios') as HTMLInputElement + fireEvent.focus(region) fireEvent.change(region, { target: { value: 'Itaparica' } }) - fireEvent.blur(region) + fireEvent.keyDown(region, { key: 'ArrowDown' }) + fireEvent.keyDown(region, { key: 'Enter' }) + expect(screen.getByText('Itaparica')).toBeTruthy() fireEvent.focus(city) - fireEvent.change(city, { target: { value: 'pa' } }) + fireEvent.change(city, { target: { value: 'Salvador' } }) fireEvent.keyDown(city, { key: 'ArrowDown' }) - expect(screen.getByRole('option', { name: 'Paulo Afonso' })).toBeTruthy() - expect(screen.queryByRole('option', { name: 'Salvador' })).toBeNull() + fireEvent.keyDown(city, { key: 'Enter' }) + + await waitFor(() => expect(screen.queryByLabelText('Territórios de identidade')).toBeNull()) + expect(screen.queryByText('Itaparica')).toBeNull() + expect(screen.getByText('Metropolitano de Salvador')).toBeTruthy() }) - it('auto-selects territory and clears neighborhood when municipality changes or clears', () => { + it('derives regions from multiple cities across territories', () => { + render(createElement(NucleusTerritoryFields)) + const city = screen.getByLabelText('Municípios') as HTMLInputElement + + fireEvent.focus(city) + fireEvent.change(city, { target: { value: 'Seab' } }) + fireEvent.keyDown(city, { key: 'ArrowDown' }) + fireEvent.keyDown(city, { key: 'Enter' }) + + fireEvent.focus(city) + fireEvent.change(city, { target: { value: 'Salv' } }) + fireEvent.keyDown(city, { key: 'ArrowDown' }) + fireEvent.keyDown(city, { key: 'Enter' }) + + expect(screen.getByText('Seabra')).toBeTruthy() + expect(screen.getByText('Salvador')).toBeTruthy() + expect(screen.getByText('Chapada Diamantina')).toBeTruthy() + expect(screen.getByText('Metropolitano de Salvador')).toBeTruthy() + }) + + it('enables neighborhoods only when exactly one city is set, resetting them otherwise', async () => { render( createElement(NucleusTerritoryFields, { - values: { - city: 'Seabra', - neighborhood: 'Centro', - }, + values: { cities: ['Seabra'], neighborhoods: ['Centro'] }, }), ) - const region = screen.getByLabelText('Território de identidade') as HTMLInputElement - const city = screen.getByLabelText('Município') as HTMLInputElement - const neighborhood = screen.getByLabelText('Bairro') as HTMLInputElement + const neighborhood = screen.getByLabelText('Bairros') as HTMLInputElement + expect(neighborhood.disabled).toBe(false) + expect(screen.getByText('Centro')).toBeTruthy() + const city = screen.getByLabelText('Municípios') as HTMLInputElement + fireEvent.focus(city) fireEvent.change(city, { target: { value: 'Salvador' } }) - fireEvent.blur(city) - expect(region.value).toBe('Metropolitano de Salvador') - expect(neighborhood.value).toBe('') + fireEvent.keyDown(city, { key: 'ArrowDown' }) + fireEvent.keyDown(city, { key: 'Enter' }) - fireEvent.change(neighborhood, { target: { value: 'Barra' } }) - fireEvent.change(city, { target: { value: '' } }) - fireEvent.blur(city) - expect(city.value).toBe('') - expect(neighborhood.value).toBe('') - expect(neighborhood.disabled).toBe(true) + await waitFor(() => expect(neighborhood.disabled).toBe(true)) + expect(screen.queryByText('Centro')).toBeNull() + }) + + it('removes a city chip and re-derives the territory from the remaining cities', async () => { + render(createElement(NucleusTerritoryFields, { values: { cities: ['Seabra', 'Salvador'] } })) + expect(screen.getByText('Chapada Diamantina')).toBeTruthy() + expect(screen.getByText('Metropolitano de Salvador')).toBeTruthy() + + fireEvent.click(screen.getByLabelText('Remover município Seabra')) + + await waitFor(() => expect(screen.queryByText('Chapada Diamantina')).toBeNull()) + expect(screen.getByText('Metropolitano de Salvador')).toBeTruthy() + expect(screen.queryByText('Seabra')).toBeNull() + }) + + it('adds multiple neighborhoods via Enter and removes the last one via Backspace', () => { + render(createElement(NucleusTerritoryFields, { values: { cities: ['Seabra'] } })) + const neighborhood = screen.getByLabelText('Bairros') as HTMLInputElement + + fireEvent.change(neighborhood, { target: { value: 'Centro' } }) + fireEvent.keyDown(neighborhood, { key: 'Enter' }) + fireEvent.change(neighborhood, { target: { value: 'Bairro Novo' } }) + fireEvent.keyDown(neighborhood, { key: 'Enter' }) + + expect(screen.getByText('Centro')).toBeTruthy() + expect(screen.getByText('Bairro Novo')).toBeTruthy() + + fireEvent.keyDown(neighborhood, { key: 'Backspace' }) + expect(screen.queryByText('Bairro Novo')).toBeNull() + expect(screen.getByText('Centro')).toBeTruthy() }) it('builds an active, scoped Payload query from URL filters', () => { @@ -217,8 +268,8 @@ describe('campaign nucleus UI contracts', () => { { or: [{ name: { contains: '58' } }, { 'tseZones.zoneNumber': { equals: 58 } }], }, - { region: { equals: 'Chapada Diamantina' } }, - { city: { equals: 'Seabra' } }, + { regions: { equals: 'Chapada Diamantina' } }, + { cities: { equals: 'Seabra' } }, { 'tseZones.zoneNumber': { equals: 58 } }, { coordinators: { exists: true } }, { confirmedVoteEstimate: { exists: false } }, @@ -242,9 +293,8 @@ describe('campaign nucleus UI contracts', () => { it('parses the progressive-enhancement create form into domain input', () => { const formData = new FormData() formData.set('name', 'Núcleo Chapada') - formData.set('region', 'Chapada Diamantina') - formData.set('city', 'Seabra') - formData.set('neighborhood', 'Centro') + formData.append('cities', 'Seabra') + formData.append('neighborhoods', 'Centro') formData.set('organizationKind', 'territorial') formData.set('tseZones', '58, 59') formData.append('coordinators', '12') @@ -252,9 +302,9 @@ describe('campaign nucleus UI contracts', () => { expect(parseNucleusCreateFormData(formData)).toEqual({ name: 'Núcleo Chapada', - region: 'Chapada Diamantina', - city: 'Seabra', - neighborhood: 'Centro', + regions: ['Chapada Diamantina'], + cities: ['Seabra'], + neighborhoods: ['Centro'], locality: undefined, territoryNotes: undefined, organizationKind: 'territorial', @@ -270,7 +320,6 @@ describe('campaign nucleus UI contracts', () => { const formData = new FormData() formData.set('id', '12') formData.set('name', 'Núcleo Chapada') - formData.set('region', '') formData.set('organizationKind', 'territorial') formData.set('locality', 'Centro') formData.append('coordinators', '99') @@ -280,9 +329,9 @@ describe('campaign nucleus UI contracts', () => { expect(parsed).toMatchObject({ id: 12, tseZones: [], - region: null, - city: null, - neighborhood: null, + regions: [], + cities: [], + neighborhoods: [], locality: 'Centro', sectorKind: null, organizationLabel: null, @@ -296,13 +345,11 @@ describe('campaign nucleus UI contracts', () => { expect(parsed).not.toHaveProperty('coordinators') }) - it('rejects clearing region, city, and locality with a linked city error', () => { + it('rejects clearing regions, cities, and locality together', () => { const formData = new FormData() formData.set('id', '12') formData.set('name', 'Núcleo Chapada') formData.set('organizationKind', 'territorial') - formData.set('region', '') - formData.set('city', '') formData.set('locality', '') expect(() => parseNucleusUpdateFormData(formData)).toThrow( @@ -310,17 +357,16 @@ describe('campaign nucleus UI contracts', () => { ) }) - it('drops neighborhood input when the city is blank', () => { + it('drops neighborhood input when no city is selected', () => { const formData = new FormData() formData.set('id', '12') formData.set('name', 'Núcleo Chapada') - formData.set('region', 'Chapada Diamantina') - formData.set('city', '') - formData.set('neighborhood', 'Centro') + formData.append('regions', 'Chapada Diamantina') + formData.append('neighborhoods', 'Centro') formData.set('locality', 'Comunidade rural') formData.set('organizationKind', 'territorial') - expect(parseNucleusUpdateFormData(formData).neighborhood).toBeNull() + expect(parseNucleusUpdateFormData(formData).neighborhoods).toEqual([]) }) it('parses Zona TSE tags as sorted unique bounded numbers', () => { @@ -360,9 +406,9 @@ describe('campaign nucleus UI contracts', () => { slug: 'nucleo-seguro', status: 'ativo', coordinators: [99], - region: 'Chapada Diamantina', - city: 'Seabra', - neighborhood: 'Centro', + regions: ['Chapada Diamantina'], + cities: ['Seabra'], + neighborhoods: ['Centro'], organizationKind: 'territorial', sectorKind: 'rural', tseZones: [], @@ -394,8 +440,7 @@ describe('campaign nucleus UI contracts', () => { it('rejects malformed Zona TSE tokens instead of dropping them', () => { const formData = new FormData() formData.set('name', 'Núcleo Chapada') - formData.set('region', 'Chapada Diamantina') - formData.set('city', 'Seabra') + formData.append('cities', 'Seabra') formData.set('organizationKind', 'territorial') formData.set('tseZones', '58, inválida') @@ -438,9 +483,9 @@ describe('campaign nucleus UI contracts', () => { name: 'Quilombo Rio das Rãs', slug: 'quilombo-rio-das-ras', coordinators: [{ id: 2, name: 'João Silva' }], - region: 'Velho Chico', - city: 'Bom Jesus da Lapa', - neighborhood: null, + regions: ['Velho Chico'], + cities: ['Bom Jesus da Lapa'], + neighborhoods: [], locality: null, organizationKind: 'territorial', organizationLabel: null, @@ -471,8 +516,8 @@ describe('campaign nucleus UI contracts', () => { ) expect(html).toContain('Nome do núcleo') - expect(html).toContain('Território de identidade') - expect(html).toContain('Município') + expect(html).toContain('Territórios de identidade') + expect(html).toContain('Municípios') expect(html).toContain('Zonas TSE') expect(html).toContain('Coordenação Geral (você)') expect(html).toContain('João Silva') @@ -501,9 +546,9 @@ describe('campaign nucleus UI contracts', () => { id: 12, name: 'Núcleo Chapada', slug: 'nucleo-chapada', - region: 'Chapada Diamantina', - city: 'Seabra', - neighborhood: null, + regions: ['Chapada Diamantina'], + cities: ['Seabra'], + neighborhoods: [], locality: null, territoryNotes: null, organizationKind: 'territorial',