From 9fdbae05d7aff1553029370135fc32d76080752b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sat, 18 Jul 2026 22:17:00 -0300 Subject: [PATCH 1/2] Add C6 scale/DRY for supporters: bulk import, KPI aggregate, shared shells. Phase 1 UX: StrictCombobox municipality in SupporterForm, AlertDialog in RemoveSupporterDataButton. Phase 2 DRY: campaignListUrl + CampaignListPagination + campaignFormFields + mapCampaignFormActionError shared across nuclei, supporters and action plans. Phase 3: single SQL aggregate for supporter list KPIs via drizzle in the Payload txn. Phase 4: bulk drizzle insert for CSV import inside the existing Payload txn with ON CONFLICT DO NOTHING, plus a fail-closed skipContactPhoneInvariant context flag. Phase 5: HMAC-signed single-use import token backed by a new supporterImportBatch staging collection so the full ok-row set never crosses the Server Action boundary twice. --- docs/plans/escala-dry-pos-c2.md | 39 +- docs/roadmap.md | 4 +- .../(app)/apoiadores/novo/formActions.ts | 27 +- .../campanha/(app)/apoiadores/page.tsx | 9 +- .../nucleos/[slug]/leadershipFormActions.ts | 40 +- .../campanha/(app)/nucleos/page.tsx | 9 +- .../(campaign)/campanha/(app)/planos/page.tsx | 10 +- .../(campaign)/campanha/actions/supporter.ts | 193 +- src/collections/Contact.ts | 14 + src/collections/SupporterImportBatch.ts | 72 + .../campaign/ActionPlanPagination.tsx | 61 - .../campaign/CampaignListPagination.tsx | 68 + src/components/campaign/LeadershipForm.tsx | 14 +- src/components/campaign/NucleusPagination.tsx | 67 - .../campaign/RemoveSupporterDataButton.tsx | 69 +- src/components/campaign/SupporterForm.tsx | 29 +- .../campaign/SupporterImportWizard.tsx | 28 +- .../campaign/SupporterPagination.tsx | 61 - src/lib/schemas/supporter.ts | 12 +- ...719_011015_add_supporter_import_batch.json | 9157 +++++++++++++++++ ...60719_011015_add_supporter_import_batch.ts | 77 + src/migrations/index.ts | 6 + src/payload-types.ts | 39 + src/payload.config.ts | 2 + src/utilities/actionPlanUi.ts | 80 +- src/utilities/campaignFormActionError.ts | 40 + src/utilities/campaignFormFields.ts | 21 + src/utilities/campaignListUrl.ts | 86 + src/utilities/nucleusUi.ts | 80 +- src/utilities/supporterImport.ts | 7 +- src/utilities/supporterImportBulk.ts | 256 + src/utilities/supporterImportToken.ts | 165 + .../supporterListOverviewAggregate.ts | 149 + src/utilities/supporterPageData.ts | 41 +- src/utilities/supporterUi.ts | 80 +- tests/e2e/campaign-supporters.e2e.spec.ts | 77 + tests/e2e/fixtures/campaignE2EFixtures.ts | 17 + tests/int/campaignSupporter.int.spec.ts | 150 +- tests/unit/campaignNucleusUi.unit.spec.ts | 8 +- tests/unit/supporterSchema.unit.spec.ts | 16 +- 40 files changed, 10712 insertions(+), 668 deletions(-) create mode 100644 src/collections/SupporterImportBatch.ts delete mode 100644 src/components/campaign/ActionPlanPagination.tsx create mode 100644 src/components/campaign/CampaignListPagination.tsx delete mode 100644 src/components/campaign/NucleusPagination.tsx delete mode 100644 src/components/campaign/SupporterPagination.tsx create mode 100644 src/migrations/20260719_011015_add_supporter_import_batch.json create mode 100644 src/migrations/20260719_011015_add_supporter_import_batch.ts create mode 100644 src/utilities/campaignFormActionError.ts create mode 100644 src/utilities/campaignFormFields.ts create mode 100644 src/utilities/campaignListUrl.ts create mode 100644 src/utilities/supporterImportBulk.ts create mode 100644 src/utilities/supporterImportToken.ts create mode 100644 src/utilities/supporterListOverviewAggregate.ts create mode 100644 tests/e2e/campaign-supporters.e2e.spec.ts diff --git a/docs/plans/escala-dry-pos-c2.md b/docs/plans/escala-dry-pos-c2.md index 9329d3de..d8ac0fdb 100644 --- a/docs/plans/escala-dry-pos-c2.md +++ b/docs/plans/escala-dry-pos-c2.md @@ -1,7 +1,7 @@ # Escala e DRY pós-C2 (apoiadores + listas) -Status: rascunho -Atualizado em: 2026-07-18 +Status: implementado (Fases 1–5) +Atualizado em: 2026-07-19 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C6) Responsável: — @@ -121,3 +121,38 @@ flowchart TD - `src/collections/Contact.ts` — `enforceUniqueContactPhone` - `scripts/seed-tse-results.mjs` — bulk drizzle em txn - AGENTS.md — `overrideAccess: false`, transações, Contact + locks, naming + +## Implementação (2026-07-19) + +Todas as cinco fases foram implementadas e validadas localmente (`tsc --noEmit`, `pnpm lint`, `pnpm test:int`, `pnpm test:unit`) contra `teqo_test`. Sem regressões nas rotas de núcleos/apoiadores/planos já entregues. + +### Fase 1 — UX alinhada +- `SupporterForm` agora usa `StrictCombobox` + `municipalityComboboxOptions()` no município (validação continua em `optionalBahiaCity`). +- `RemoveSupporterDataButton` trocou `window.confirm` por `AlertDialog` no padrão de `ArchiveNucleusDialog.tsx`. + +### Fase 2 — DRY com núcleos / leadership +- `src/utilities/campaignListUrl.ts` (novo): `firstValue`, `normalizedText`, `strictDecimalInteger`, `inspectRawListParams`, `resolveListUrl`, `buildListHref`. `nucleusUi.ts`, `supporterUi.ts` e `actionPlanUi.ts` delegam para ele (domínio permanece nos módulos de domínio). +- `src/components/campaign/CampaignListPagination.tsx` (novo) + `getPaginationPages`: substitui `NucleusPagination`, `SupporterPagination` e `ActionPlanPagination` (os três foram removidos). Listagens de núcleos/apoiadores/planos e o teste unitário `campaignNucleusUi` apontam para o novo componente. +- `src/utilities/campaignFormFields.ts` (novo): `fieldError` / `errorProps` compartilhados por `SupporterForm` e `LeadershipForm` (cada form liga um `errorProps` local ao seu prefixo de id). +- `src/utilities/campaignFormActionError.ts` (novo): `mapCampaignFormActionError` consolida FormDataBoundaryError → Zod → safelist → genérico. `apoiadores/novo/formActions.ts`, `apoiadores/[id]/formActions.ts` e `nucleos/[slug]/leadershipFormActions.ts` usam o mapper. + +### Fase 3 — KPI da lista +- `src/utilities/supporterListOverviewAggregate.ts` (novo): `computeSupporterListOverviewAggregate` faz um único `SELECT COUNT(*) FILTER (...)` via drizzle na sessão da txn Payload, espelhando `buildSupporterListWhere` + a constraint de acesso (`nucleus_id IN (...)` para coordenador, sem constraint para geral, `none` para liderança). `loadSupporterListOverviewData` passou de 3× `payload.count` para um round-trip. Testes int cobrem geral, coordenador (escopo + filtro de cidade) e base vazia. + +### Fase 4 — Import em escala +- `src/utilities/supporterImportBulk.ts` (novo): `bulkInsertSupporterImport` insere contacts e supporters via `payload.db.drizzle` na sessão da txn Payload (`getPostgresTransactionDatabase`), em chunks de 500, com `ON CONFLICT DO NOTHING` no índice unique `(contact_id, nucleus_id)`. Substitui até 10k `payload.create` por poucos inserts bulk. +- `src/collections/Contact.ts`: `enforceUniqueContactPhone` honra `context.skipContactPhoneInvariant` **fail-closed** — só respeita quando `req.transactionID` está ativo (sem txn, os locks xact-level não podem estar segurados → throw). `upsertContactByPhone` passa o context (o caller já segurou os locks), eliminando o re-lock/re-check redundante do path single-create. + +### Fase 5 — Preview sem round-trip +- `src/collections/SupporterImportBatch.ts` (novo, admin hidden, acesso `geral`/admin) + migration `20260719_011015_add_supporter_import_batch`: staging efêmero do conjunto `ok` no servidor. +- `src/utilities/supporterImportToken.ts` (novo): token HMAC-SHA256(`${batchId}.${actorID}.${expiresAt}`, PAYLOAD_SECRET) com `timingSafeEqual` e TTL 10 min. `previewSupporterImportText` agora retorna `{ counts, sampleRows (100), errorReportCsv, importToken }` e estageia o conjunto `ok` em `supporterImportBatch`. `confirmSupporterImportRecord` aceita `importToken` (schema trocou `rows` por `importToken`), verifica HMAC + ator + expiração, consome o lote (single-use, delete após commit) e alimenta o bulk insert. O wizard parou de guardar/reenviar as rows. +- Testes int: fluxo preview→confirm; token tampered (assinatura alterada) rejeitado; token de outro ator rejeitado; reuso de token consumido rejeitado. + +## Achados da auditoria (decisões de implementação) + +- **Nomes de coluna drizzle para relationships NÃO levam sufixo `Id`.** Em `payload.db.tables.supporter`, as colunas de relationship são `contact`, `nucleus`, `consent`, `voteIntentionConsent`, `createdBy` — não `contactId`, etc. O insert bulk inicial usou `contactId`/`createdById` e o drizzle silenciou essas chaves (gerou `default` → violação NOT NULL). Corrigido para os nomes sem sufixo. Lição para C7 e futuros inserts bulk: inspecionar `Object.keys(payload.db.tables.)` antes de montar as rows. +- **`payload-preferences` não serve de staging genérico.** Tem campo `user` (relationship users/campaignUser) obrigatório e validado em `beforeChange`, e semântica de preferência por usuário. Tentar usá-lo para o lote de importação falhou com `ValidationError: User`. Decidido pela tabela dedicada `supporterImportBatch` (migration), como o plano já previa como fallback. Não há Redis novo no v1 do C6. +- **Bulk insert deve rodar na txn Payload existente, não numa txn drizzle nova.** O seed TSE (`electionResultsImport.ts`) abre `dbAdapter.drizzle.transaction(...)` própria; para C4 isso quebraria a atomicidade com os locks advisory. Usado `getPostgresTransactionDatabase(payload, req)` para pegar a sessão drizzle vinculada à txn Payload. +- **`skipContactPhoneInvariant` é fail-closed por txn, não por introspecção de lock.** Não há maneira barata de perguntar ao PG "seguro o lock X?"; o proxy usado é `req.transactionID` ativo (o lock `pg_advisory_xact_lock` é xact-level e só existe dentro de uma txn). Sem txn + flag setado → throw 500. +- **`supporter.beforeChange` (coexistência com liderança) é no-op para import sem núcleo.** O hook só dispara quando `contactID` e `nucleusID` estão presentes; import cria supporters sem núcleo, então o bypass via drizzle insert é seguro. `createdBy` é setado explicitamente no row bulk. +- **Escopo do mapper de form errors:** `ActionPlanPagination` e os forms de planos também consomem `campaignListUrl`/`CampaignListPagination`, mas o mapper de form actions de planos ficou para C7 (escopo de copy/mensagens diferente). diff --git a/docs/roadmap.md b/docs/roadmap.md index fc7a5839..bde596e0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6/C7 registrados — escala/DRY pós-C2 e pós-C3; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`) +Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado (Fases 1–5) — aguarda deploy; C7 registrado — escala/DRY pós-C3; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`) Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos. @@ -184,7 +184,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (vi | Ordem | Item | Plano | Depende de | Paralelizável com | | ----- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------- | ----------------- | | 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta e mesclada em `main` 2026-07-18 — deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3, C6, C7 | -| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3, C7 | +| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) **(implementado 2026-07-19 — Fases 1–5; aguarda deploy)** | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3, C7 | | 9 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7 | | 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7 | | 11 | C7 Escala e DRY pós-C3 (território/contato compostos, leituras por aba, RMW/índice) | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6 | diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts index 3db2cda4..faa5a8dd 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts @@ -1,17 +1,15 @@ 'use server' import { revalidatePath } from 'next/cache' -import { ZodError } from 'zod' import { createSupporter } from '@/app/(campaign)/campanha/actions/supporter' import { checkboxFormValue, - FormDataBoundaryError, nullableRelationshipFormValue, optionalFormText, - validationFieldErrors, } from '@/lib/formData' import { supporterCreateSchema } from '@/lib/schemas/supporter' +import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' import { sanitizeBrazilianPhoneInput } from '@/utilities/phone' export type SupporterFormState = { @@ -45,25 +43,14 @@ const getSupporterFormError = ( error: unknown, values?: SupporterFormValues, revision?: number, -): SupporterFormState => { - if (error instanceof FormDataBoundaryError) { - return { fieldErrors: { [error.field]: [error.message] }, values, revision } - } - if (error instanceof ZodError) { - return { fieldErrors: validationFieldErrors(error), values, revision } - } - if ( - error instanceof Error && - safeActionMessages.includes(error.message as (typeof safeActionMessages)[number]) - ) { - return { message: error.message, values, revision } - } - return { - message: 'Não foi possível cadastrar o apoiador. Verifique os dados e tente novamente.', +): SupporterFormState => + mapCampaignFormActionError({ + error, + safeMessages: safeActionMessages, + genericMessage: 'Não foi possível cadastrar o apoiador. Verifique os dados e tente novamente.', values, revision, - } -} + }) export const createSupporterFormAction = async ( state: SupporterFormState, diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx index d451a134..703d6570 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx @@ -4,11 +4,11 @@ import { redirect } from 'next/navigation' import config from '@payload-config' import { getPayload } from 'payload' +import { CampaignListPagination } from '@/components/campaign/CampaignListPagination' import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' import { SupporterFilters } from '@/components/campaign/SupporterFilters' import { SupporterList } from '@/components/campaign/SupporterList' import { SupporterListOverview } from '@/components/campaign/SupporterListOverview' -import { SupporterPagination } from '@/components/campaign/SupporterPagination' import { Button } from '@/components/ui/button' import { Empty, @@ -28,6 +28,7 @@ import { import { toSupporterListItemViewModel } from '@/utilities/supporterViewModels' import { buildSupporterFiltersKey, + buildSupporterListHref, canAccessSupporterArea, getSupporterScopeLabel, resolveSupporterListUrl, @@ -101,7 +102,11 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro {result.totalDocs}{' '} {result.totalDocs === 1 ? 'apoiador encontrado' : 'apoiadores encontrados'}

- + buildSupporterListHref(state, page)} + /> ) : ( diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/leadershipFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/leadershipFormActions.ts index 1cca5c12..9386038d 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/leadershipFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/leadershipFormActions.ts @@ -1,7 +1,6 @@ 'use server' import { revalidatePath } from 'next/cache' -import { ZodError } from 'zod' import { createLeadership, @@ -14,8 +13,8 @@ import { nullableFormText, optionalFormText, requiredRelationshipFormValue, - validationFieldErrors, } from '@/lib/formData' +import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' export type LeadershipFormState = { status?: 'success' @@ -46,35 +45,26 @@ const safeActionMessages = [ 'Somente a coordenação pode gerenciar lideranças.', ] as const +const resolveLeadershipBoundaryMessage = (error: FormDataBoundaryError): string => + error.field === 'contact' + ? 'Não foi possível identificar a liderança. Atualize a página e tente novamente.' + : error.field === 'id' || error.field === 'nucleus' + ? 'Não foi possível identificar o registro. Atualize a página e tente novamente.' + : error.message + const getLeadershipFormError = ( error: unknown, values?: LeadershipFormValues, revision?: number, -): LeadershipFormState => { - if (error instanceof FormDataBoundaryError) { - const message = - error.field === 'contact' - ? 'Não foi possível identificar a liderança. Atualize a página e tente novamente.' - : error.field === 'id' || error.field === 'nucleus' - ? 'Não foi possível identificar o registro. Atualize a página e tente novamente.' - : error.message - return { fieldErrors: { [error.field]: [message] }, values, revision } - } - if (error instanceof ZodError) { - return { fieldErrors: validationFieldErrors(error), values, revision } - } - if ( - error instanceof Error && - safeActionMessages.includes(error.message as (typeof safeActionMessages)[number]) - ) { - return { message: error.message, values, revision } - } - return { - message: 'Não foi possível salvar a liderança. Verifique os dados e tente novamente.', +): LeadershipFormState => + mapCampaignFormActionError({ + error, + safeMessages: safeActionMessages, + genericMessage: 'Não foi possível salvar a liderança. Verifique os dados e tente novamente.', values, revision, - } -} + resolveBoundaryMessage: resolveLeadershipBoundaryMessage, + }) export const createLeadershipFormAction = async ( state: LeadershipFormState, diff --git a/src/app/(campaign)/campanha/(app)/nucleos/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/page.tsx index 370cdd0e..c0242f4c 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/nucleos/page.tsx @@ -6,10 +6,10 @@ import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' +import { CampaignListPagination } from '@/components/campaign/CampaignListPagination' import { NucleusFilters } from '@/components/campaign/NucleusFilters' import { NucleusList } from '@/components/campaign/NucleusList' import { NucleusListOverview } from '@/components/campaign/NucleusListOverview' -import { NucleusPagination } from '@/components/campaign/NucleusPagination' import { Button } from '@/components/ui/button' import { Empty, @@ -24,6 +24,7 @@ import { loadNucleusListOverviewData } from '@/utilities/nucleusListOverviewPage import { loadNucleusListPageData } from '@/utilities/nucleusPageData' import { buildNucleusFiltersKey, + buildNucleusListHref, getCampaignScopeLabel, resolveNucleusListUrl, } from '@/utilities/nucleusUi' @@ -89,7 +90,11 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) { {result.totalDocs}{' '} {result.totalDocs === 1 ? 'núcleo encontrado' : 'núcleos encontrados'}

- + buildNucleusListHref(state, page)} + /> ) : ( diff --git a/src/app/(campaign)/campanha/(app)/planos/page.tsx b/src/app/(campaign)/campanha/(app)/planos/page.tsx index b72ddecc..efa88550 100644 --- a/src/app/(campaign)/campanha/(app)/planos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/planos/page.tsx @@ -6,7 +6,7 @@ import { getPayload } from 'payload' import { ActionPlanFilters } from '@/components/campaign/ActionPlanFilters' import { ActionPlanList } from '@/components/campaign/ActionPlanList' -import { ActionPlanPagination } from '@/components/campaign/ActionPlanPagination' +import { CampaignListPagination } from '@/components/campaign/CampaignListPagination' import { Button } from '@/components/ui/button' import { Empty, @@ -19,7 +19,7 @@ import { import type { ActionPlan } from '@/payload-types' import { getCampaignUser } from '@/utilities/campaignAuth' import { loadActionPlanListPageData } from '@/utilities/actionPlanPageData' -import { buildActionPlanFiltersKey, resolveActionPlanListUrl } from '@/utilities/actionPlanUi' +import { buildActionPlanFiltersKey, buildActionPlanListHref, resolveActionPlanListUrl } from '@/utilities/actionPlanUi' import { toActionPlanListViewModel } from '@/utilities/actionPlanViewModels' type ActionPlanListPageProps = { @@ -71,7 +71,11 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis

{result.totalDocs} {result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'}

- + buildActionPlanListHref(state, page)} + /> ) : ( diff --git a/src/app/(campaign)/campanha/actions/supporter.ts b/src/app/(campaign)/campanha/actions/supporter.ts index 02fe6f69..965a7a7d 100644 --- a/src/app/(campaign)/campanha/actions/supporter.ts +++ b/src/app/(campaign)/campanha/actions/supporter.ts @@ -23,6 +23,14 @@ import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { normalizeBrazilianPhone } from '@/utilities/phone' import { relationshipId, requireRelationshipId } from '@/utilities/relationship' +import { bulkInsertSupporterImport } from '@/utilities/supporterImportBulk' +import { + issueSupporterImportToken, + loadSupporterImportBatch, + deleteSupporterImportBatch, + storeSupporterImportBatch, + verifySupporterImportToken, +} from '@/utilities/supporterImportToken' import { isSupporterImportOkRow, type SupporterImportPreviewResult, @@ -39,6 +47,35 @@ export type { const MAX_IMPORT_ROWS = 5000 +/** On-screen preview is capped; the full ok set is staged server-side (Phase 5). */ +const SUPPORTER_IMPORT_SAMPLE_SIZE = 100 + +const isPreviewErrorRow = (row: SupporterImportPreviewRow): boolean => + row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone' + +const importStatusCsvValue: Record = { + ok: 'ok', + duplicado_pelo_telefone: 'duplicado_pelo_telefone', + telefone_invalido: 'telefone_invalido', + municipio_nao_reconhecido: 'municipio_nao_reconhecido', + nome_invalido: 'nome_invalido', + intencao_invalida: 'intencao_invalida', +} + +const escapeCsvCell = (value: string): string => `"${value.replace(/"/g, '""')}"` + +const buildSupporterImportErrorReportCsv = (rows: SupporterImportPreviewRow[]): string => { + const header = 'linha,nome,telefone,municipio,intencao,status\n' + const body = rows + .filter(isPreviewErrorRow) + .map( + (row) => + `${row.line},${escapeCsvCell(row.nome)},${escapeCsvCell(row.telefone)},${escapeCsvCell(row.municipio)},${escapeCsvCell(row.intencao)},${importStatusCsvValue[row.status]}`, + ) + .join('\n') + return header + body +} + const getFreshStaffActor = async ( payload: Payload, actor: CampaignUser, @@ -149,6 +186,10 @@ const upsertContactByPhone = async ({ }, depth: 0, overrideAccess: true, + // Callers of upsertContactByPhone have already acquired the phone advisory + // lock in the same transaction, so the Contact phone-invariant hook can + // skip its redundant lock+availability check. + context: { skipContactPhoneInvariant: true }, req, }) @@ -333,7 +374,7 @@ export const previewSupporterImportText = async ( actor: CampaignUser, csvText: string, ): Promise => { - await getFreshGeneralActor(payload, actor) + const currentActor = await getFreshGeneralActor(payload, actor) await requireSupporterRegistrationConsent(payload) await requireSupporterVoteIntentionConsent(payload) @@ -490,7 +531,31 @@ export const previewSupporterImportText = async ( .length, } - return { rows, counts } + const okRows = rows.filter(isSupporterImportOkRow).map((row) => ({ + nome: row.nome, + telefone: row.normalizedPhone, + municipio: row.canonicalCity, + intencao: row.voteIntention, + })) + + const errorReportCsv = buildSupporterImportErrorReportCsv(rows) + const sampleRows = rows.slice(0, SUPPORTER_IMPORT_SAMPLE_SIZE) + + // Stage the full ok set server-side keyed by an HMAC-signed token so the wizard + // never has to hold/re-send thousands of rows across the Server Action boundary. + const issued = issueSupporterImportToken(currentActor.id) + await storeSupporterImportBatch(payload, issued.batchId, { + actorID: currentActor.id, + expiresAt: issued.expiresAt, + okRows, + }) + + return { + counts, + sampleRows, + errorReportCsv, + importToken: issued.token, + } } export const previewSupporterImport = async (csvText: string) => { @@ -505,6 +570,14 @@ export const confirmSupporterImportRecord = async ( ) => { const data = supporterImportConfirmSchema.parse(input) + // Verify the HMAC token and load the staged batch BEFORE opening the write + // transaction, so an invalid/expired token is rejected without holding locks. + const verified = verifySupporterImportToken(data.importToken, actor.id) + const batch = await loadSupporterImportBatch(payload, verified.batchId, actor.id) + if (batch.okRows.length === 0) { + throw new Error('O lote de importação não contém apoiadores válidos.') + } + return withPayloadTransaction( payload, async ({ req }) => { @@ -516,111 +589,31 @@ export const confirmSupporterImportRecord = async ( throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.') } - const phones = [...new Set(data.rows.map((row) => row.telefone))] + const phones = [...new Set(batch.okRows.map((row) => row.telefone))] await acquireContactPhoneLocks(payload, req, phones) - const existingContacts = await payload.find({ - collection: 'contact', - where: { phone: { in: phones } }, - depth: 0, - limit: phones.length, - pagination: false, - select: { phone: true }, - overrideAccess: true, - req, - }) - const contactIdByPhone = new Map( - existingContacts.docs.map((doc) => [doc.phone, doc.id] as const), - ) - - for (const row of data.rows) { - if (contactIdByPhone.has(row.telefone)) continue - const { contactID } = await upsertContactByPhone({ - payload, - req, - phone: row.telefone, - name: row.nome, - city: row.municipio, - }) - contactIdByPhone.set(row.telefone, contactID) - } - - const contactIDs = [...new Set(contactIdByPhone.values())] - const existingSupporters = await payload.find({ - collection: 'supporter', - where: { - and: [{ contact: { in: contactIDs } }, { nucleus: { exists: false } }], - }, - depth: 0, - limit: contactIDs.length, - pagination: false, - select: { contact: true }, - overrideAccess: true, - req, - }) - const supporterContactIDs = new Set( - existingSupporters.docs - .map((doc) => relationshipId(doc.contact)) - .filter((id): id is number => id !== null), - ) - - let created = 0 - let skipped = 0 - const errors: Array<{ telefone: string; message: string }> = [] const consentNote = data.consentNote ?? 'Importação CSV com atestado do operador sobre consentimento dos titulares.' - const consentedAt = new Date().toISOString() - for (const row of data.rows) { - const contactID = contactIdByPhone.get(row.telefone) - if (!contactID) { - skipped += 1 - errors.push({ telefone: row.telefone, message: 'Contato não encontrado após upsert.' }) - continue - } + const result = await bulkInsertSupporterImport({ + payload, + req, + actorID: currentActor.id, + rows: batch.okRows.map((row) => ({ + telefone: row.telefone, + nome: row.nome, + municipio: row.municipio, + intencao: row.intencao, + })), + registrationConsent, + voteIntentionConsent, + consentNote, + }) - if (supporterContactIDs.has(contactID)) { - skipped += 1 - continue - } - - try { - await payload.create({ - collection: 'supporter', - data: { - contact: contactID, - source: 'import_csv', - voteIntention: row.intencao, - consent: registrationConsent.id, - consentContentHash: registrationConsent.contentHash, - consentedAt, - consentNote, - ...(row.intencao - ? { - voteIntentionConsent: voteIntentionConsent.id, - voteIntentionConsentContentHash: voteIntentionConsent.contentHash, - voteIntentionConsentedAt: consentedAt, - } - : {}), - createdBy: currentActor.id, - }, - depth: 0, - overrideAccess: true, - req, - }) - supporterContactIDs.add(contactID) - created += 1 - } catch (error) { - skipped += 1 - errors.push({ - telefone: row.telefone, - message: error instanceof Error ? error.message : 'Erro ao importar linha.', - }) - } - } - - return { created, skipped, errors } + // Single-use token: drop the staged batch once the import commits. + await deleteSupporterImportBatch(payload, verified.batchId, req) + return result }, { beginFailureMessage: 'Não foi possível iniciar a transação de importação de apoiadores.' }, ) diff --git a/src/collections/Contact.ts b/src/collections/Contact.ts index 01d7ff82..87f7c0e0 100644 --- a/src/collections/Contact.ts +++ b/src/collections/Contact.ts @@ -17,6 +17,20 @@ const enforceUniqueContactPhone: CollectionBeforeChangeHook = async ({ originalDoc, req, }) => { + // Opt-out for callers that have already acquired the phone advisory locks in the + // SAME Payload transaction (e.g. bulk import). Fail closed: the flag is only + // honored inside an active transaction — without one the xact-level advisory + // lock cannot be held, so we run the full check regardless of the caller's claim. + if (req.context?.skipContactPhoneInvariant === true) { + if (req.transactionID === undefined || req.transactionID === null) { + throw new APIError( + 'skipContactPhoneInvariant exige uma transação ativa com os locks de telefone já adquiridos.', + 500, + ) + } + return data + } + const phone = String(data.phone ?? originalDoc?.phone ?? '') const oldPhone = operation === 'update' && typeof originalDoc?.phone === 'string' diff --git a/src/collections/SupporterImportBatch.ts b/src/collections/SupporterImportBatch.ts new file mode 100644 index 00000000..b4582f3f --- /dev/null +++ b/src/collections/SupporterImportBatch.ts @@ -0,0 +1,72 @@ +import type { Access, CollectionConfig } from 'payload' + +import { getFreshCampaignUser, isCampaignGeneral, isPayloadAdmin } from '@/utilities/campaignAccess' + +/** + * Short-lived server-side staging for the supporter CSV import wizard (roadmap + * C6, Phase 5). The preview stores the full `ok` row set here keyed by an + * HMAC-signed token; the confirm step redeems the token, consumes the batch, + * and bulk-inserts. This keeps thousands of rows from crossing the Server + * Action boundary twice. + * + * The collection is hidden from the admin nav and restricted to `geral` / + * Payload-admin; the import actions interact with it via the Local API with + * `overrideAccess: true`. Rows hold transient supporter PII and must be + * purged on consume or after `expiresAt` (lazy cleanup + sweep). + */ +const canAccessImportBatch: Access = async ({ req }) => { + if (isPayloadAdmin(req.user)) return true + const fresh = await getFreshCampaignUser(req) + return isCampaignGeneral(fresh) +} + +export const SupporterImportBatch: CollectionConfig = { + slug: 'supporterImportBatch', + labels: { + singular: 'Lote de importação', + plural: 'Lotes de importação', + }, + admin: { + group: 'Campanha', + hidden: () => true, + useAsTitle: 'batchId', + defaultColumns: ['batchId', 'actor', 'expiresAt'], + }, + access: { + create: canAccessImportBatch, + read: canAccessImportBatch, + update: canAccessImportBatch, + delete: canAccessImportBatch, + }, + fields: [ + { + name: 'batchId', + type: 'text', + label: 'ID do lote', + required: true, + unique: true, + index: true, + }, + { + name: 'actor', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Operador', + required: true, + index: true, + }, + { + name: 'expiresAt', + type: 'date', + label: 'Expira em', + required: true, + index: true, + }, + { + name: 'rows', + type: 'json', + label: 'Linhas válidas', + required: true, + }, + ], +} diff --git a/src/components/campaign/ActionPlanPagination.tsx b/src/components/campaign/ActionPlanPagination.tsx deleted file mode 100644 index 713d825d..00000000 --- a/src/components/campaign/ActionPlanPagination.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { getNucleusPaginationPages } from '@/components/campaign/NucleusPagination' -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from '@/components/ui/Pagination' -import { buildActionPlanListHref, type ActionPlanListState } from '@/utilities/actionPlanUi' - -export const ActionPlanPagination = ({ - state, - totalPages, -}: { - state: ActionPlanListState - totalPages: number -}) => { - if (totalPages <= 1) return null - - const visiblePages = getNucleusPaginationPages(state.page, totalPages) - - return ( - - - - - - {visiblePages.map((page, index) => { - const previousPage = visiblePages[index - 1] - const hasGap = previousPage !== undefined && page - previousPage > 1 - - return ( - - {hasGap ? : null} - - {page} - - - ) - })} - - = totalPages} - className={state.page >= totalPages ? 'pointer-events-none opacity-50' : undefined} - /> - - - - ) -} diff --git a/src/components/campaign/CampaignListPagination.tsx b/src/components/campaign/CampaignListPagination.tsx new file mode 100644 index 00000000..9270e33d --- /dev/null +++ b/src/components/campaign/CampaignListPagination.tsx @@ -0,0 +1,68 @@ +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@/components/ui/Pagination' + +export const getPaginationPages = (page: number, totalPages: number): number[] => { + const pages = new Set([1, totalPages, page - 1, page, page + 1]) + return [...pages] + .filter((candidate) => candidate >= 1 && candidate <= totalPages) + .sort((a, b) => a - b) +} + +export const CampaignListPagination = ({ + page, + totalPages, + hrefForPage, +}: { + page: number + totalPages: number + hrefForPage: (page: number) => string +}) => { + if (totalPages <= 1) return null + + const visiblePages = getPaginationPages(page, totalPages) + + return ( + + + + + + {visiblePages.map((pageNumber, index) => { + const previousPage = visiblePages[index - 1] + const hasGap = previousPage !== undefined && pageNumber - previousPage > 1 + + return ( + + {hasGap ? : null} + + {pageNumber} + + + ) + })} + + = totalPages} + className={page >= totalPages ? 'pointer-events-none opacity-50' : undefined} + /> + + + + ) +} diff --git a/src/components/campaign/LeadershipForm.tsx b/src/components/campaign/LeadershipForm.tsx index 7917f2b8..073a5976 100644 --- a/src/components/campaign/LeadershipForm.tsx +++ b/src/components/campaign/LeadershipForm.tsx @@ -27,6 +27,7 @@ import { Textarea } from '@/components/ui/textarea' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup' import { leadershipGenderLabels, leadershipSectorLabels } from '@/utilities/leadershipUi' import type { LeadershipEditViewModel } from '@/utilities/leadershipViewModels' +import { fieldError, errorProps as buildErrorProps } from '@/utilities/campaignFormFields' import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone' export type LeadershipFormAction = ( @@ -49,19 +50,10 @@ const supportStatusOptions = [ { value: 'negativo', label: 'Negativo' }, ] as const -const fieldError = ( +const errorProps = ( fieldErrors: Record | undefined, field: string, -): string | undefined => fieldErrors?.[field]?.[0] - -const errorProps = (fieldErrors: Record | undefined, field: string) => { - const error = fieldError(fieldErrors, field) - return { - error, - invalid: Boolean(error), - describedBy: error ? `leadership-${field}-error` : undefined, - } -} +) => buildErrorProps(fieldErrors, field, 'leadership') export const LeadershipFormFeedback = ({ message, diff --git a/src/components/campaign/NucleusPagination.tsx b/src/components/campaign/NucleusPagination.tsx deleted file mode 100644 index 4cbcb389..00000000 --- a/src/components/campaign/NucleusPagination.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from '@/components/ui/Pagination' -import { buildNucleusListHref, type NucleusListState } from '@/utilities/nucleusUi' - -export const getNucleusPaginationPages = (page: number, totalPages: number): number[] => { - const pages = new Set([1, totalPages, page - 1, page, page + 1]) - return [...pages] - .filter((candidate) => candidate >= 1 && candidate <= totalPages) - .sort((a, b) => a - b) -} - -export const NucleusPagination = ({ - state, - totalPages, -}: { - state: NucleusListState - totalPages: number -}) => { - if (totalPages <= 1) return null - - const visiblePages = getNucleusPaginationPages(state.page, totalPages) - - return ( - - - - - - {visiblePages.map((page, index) => { - const previousPage = visiblePages[index - 1] - const hasGap = previousPage !== undefined && page - previousPage > 1 - - return ( - - {hasGap ? : null} - - {page} - - - ) - })} - - = totalPages} - className={state.page >= totalPages ? 'pointer-events-none opacity-50' : undefined} - /> - - - - ) -} diff --git a/src/components/campaign/RemoveSupporterDataButton.tsx b/src/components/campaign/RemoveSupporterDataButton.tsx index 118ff054..e28167e3 100644 --- a/src/components/campaign/RemoveSupporterDataButton.tsx +++ b/src/components/campaign/RemoveSupporterDataButton.tsx @@ -5,6 +5,16 @@ import { useActionState } from 'react' import type { SupporterRemoveFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions' import { removeSupporterDataFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions' import { Alert, AlertDescription } from '@/components/ui/Alert' +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/AlertDialog' import { Button } from '@/components/ui/button' import { Spinner } from '@/components/ui/Spinner' @@ -21,32 +31,39 @@ export const RemoveSupporterDataButton = ({ supporterId }: { supporterId: number Exercício do direito de eliminação (LGPD art. 18). Remove o cadastro de apoiador e anonimiza o contato quando não houver outros vínculos.

- {state.message ? ( - - {state.message} - - ) : null} -
- - -
+ + + + + + + Remover os dados deste apoiador? + + Esta ação não pode ser desfeita. O cadastro de apoiador será removido e o contato + anonimizado quando não houver outros vínculos. + + + {state.message ? ( + + {state.message} + + ) : null} +
+ + + + Cancelar + + + +
+
+
) } diff --git a/src/components/campaign/SupporterForm.tsx b/src/components/campaign/SupporterForm.tsx index fb16c16d..c6d9b0e3 100644 --- a/src/components/campaign/SupporterForm.tsx +++ b/src/components/campaign/SupporterForm.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner' import type { SupporterFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions' import { FormattedInput } from '@/components/FormattedInput' +import { StrictCombobox } from '@/components/campaign/StrictCombobox' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/Checkbox' @@ -22,28 +23,21 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' import { Spinner } from '@/components/ui/Spinner' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup' import type { SupporterVoteIntention } from '@/lib/schemas/supporter' +import { fieldError, errorProps as buildErrorProps } from '@/utilities/campaignFormFields' import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone' import { supporterVoteIntentionLabels } from '@/utilities/supporterUi' import type { SupporterNucleusOption } from '@/utilities/supporterViewModels' +import { municipalityComboboxOptions } from '@/utilities/territoryComboboxOptions' export type SupporterFormAction = ( state: SupporterFormState, formData: FormData, ) => Promise -const fieldError = ( +const errorProps = ( fieldErrors: Record | undefined, field: string, -): string | undefined => fieldErrors?.[field]?.[0] - -const errorProps = (fieldErrors: Record | undefined, field: string) => { - const error = fieldError(fieldErrors, field) - return { - error, - invalid: Boolean(error), - describedBy: error ? `supporter-${field}-error` : undefined, - } -} +) => buildErrorProps(fieldErrors, field, 'supporter') const ConsentBlock = ({ id, @@ -115,6 +109,7 @@ export const SupporterForm = ({ const [voteIntention, setVoteIntention] = useState( (state.values?.voteIntention as SupporterVoteIntention | undefined) ?? '', ) + const [city, setCity] = useState((state.values?.city as string | undefined) ?? '') const values = state.values useEffect(() => { @@ -204,13 +199,13 @@ export const SupporterForm = ({ Município - + {cityField.error ? ( {cityField.error} diff --git a/src/components/campaign/SupporterImportWizard.tsx b/src/components/campaign/SupporterImportWizard.tsx index 1d986574..e265134b 100644 --- a/src/components/campaign/SupporterImportWizard.tsx +++ b/src/components/campaign/SupporterImportWizard.tsx @@ -29,7 +29,6 @@ import { Textarea } from '@/components/ui/textarea' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup' import { cn } from '@/lib/utils' import { - isSupporterImportOkRow, type SupporterImportPreviewResult, type SupporterImportPreviewRow, } from '@/utilities/supporterImport' @@ -49,16 +48,8 @@ const steps = ['Upload', 'Conferir prévia', 'Confirmação'] as const const isPreviewErrorRow = (row: SupporterImportPreviewRow): boolean => row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone' -const downloadErrorReport = (rows: SupporterImportPreviewRow[]) => { - const errorRows = rows.filter(isPreviewErrorRow) - const header = 'linha,nome,telefone,municipio,intencao,status\n' - const body = errorRows - .map( - (row) => - `${row.line},"${row.nome.replace(/"/g, '""')}","${row.telefone}","${row.municipio}","${row.intencao}",${row.status}`, - ) - .join('\n') - const blob = new Blob([header + body], { type: 'text/csv;charset=utf-8' }) +const downloadErrorReport = (errorReportCsv: string) => { + const blob = new Blob([errorReportCsv], { type: 'text/csv;charset=utf-8' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url @@ -99,24 +90,19 @@ export const SupporterImportWizard = () => { } const visibleRows = - preview?.rows.filter((row) => (previewFilter === 'errors' ? isPreviewErrorRow(row) : true)) ?? - [] + preview?.sampleRows.filter((row) => + previewFilter === 'errors' ? isPreviewErrorRow(row) : true, + ) ?? [] const confirmImport = () => { if (!preview) return setError(undefined) startTransition(async () => { try { - const rows = preview.rows.filter(isSupporterImportOkRow).map((row) => ({ - nome: row.nome, - telefone: row.normalizedPhone, - municipio: row.canonicalCity, - intencao: row.voteIntention, - })) const result = await confirmSupporterImport({ operatorAttested: true, consentNote: consentNote.trim() || undefined, - rows, + importToken: preview.importToken, }) setConfirmResult({ created: result.created, skipped: result.skipped }) setStep(2) @@ -210,7 +196,7 @@ export const SupporterImportWizard = () => { type="button" variant="outline" className="min-h-11" - onClick={() => downloadErrorReport(preview.rows)} + onClick={() => preview && downloadErrorReport(preview.errorReportCsv)} > Baixar relatório de erros diff --git a/src/components/campaign/SupporterPagination.tsx b/src/components/campaign/SupporterPagination.tsx deleted file mode 100644 index fff2d07c..00000000 --- a/src/components/campaign/SupporterPagination.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from '@/components/ui/Pagination' -import { getNucleusPaginationPages } from '@/components/campaign/NucleusPagination' -import { buildSupporterListHref, type SupporterListState } from '@/utilities/supporterUi' - -export const SupporterPagination = ({ - state, - totalPages, -}: { - state: SupporterListState - totalPages: number -}) => { - if (totalPages <= 1) return null - - const visiblePages = getNucleusPaginationPages(state.page, totalPages) - - return ( - - - - - - {visiblePages.map((page, index) => { - const previousPage = visiblePages[index - 1] - const hasGap = previousPage !== undefined && page - previousPage > 1 - - return ( - - {hasGap ? : null} - - {page} - - - ) - })} - - = totalPages} - className={state.page >= totalPages ? 'pointer-events-none opacity-50' : undefined} - /> - - - - ) -} diff --git a/src/lib/schemas/supporter.ts b/src/lib/schemas/supporter.ts index 6661200b..a37728a6 100644 --- a/src/lib/schemas/supporter.ts +++ b/src/lib/schemas/supporter.ts @@ -79,17 +79,7 @@ export const supporterImportConfirmSchema = z.object({ error: 'É necessário atestar o consentimento dos apoiadores importados.', }), consentNote: trimmedNullableText(2000), - rows: z - .array( - z.object({ - nome: z.string().trim().min(2).max(120), - telefone: brazilianMobile, - municipio: optionalBahiaCity, - intencao: z.enum(supporterVoteIntentions).optional(), - }), - ) - .min(1) - .max(5000), + importToken: z.string().min(1, { error: 'Token de importação ausente.' }), }) export const supporterRemoveSchema = z.object({ diff --git a/src/migrations/20260719_011015_add_supporter_import_batch.json b/src/migrations/20260719_011015_add_supporter_import_batch.json new file mode 100644 index 00000000..d1a3f0d7 --- /dev/null +++ b/src/migrations/20260719_011015_add_supporter_import_batch.json @@ -0,0 +1,9157 @@ +{ + "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.supporter": { + "name": "supporter", + "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": false + }, + "vote_intention": { + "name": "vote_intention", + "type": "enum_supporter_vote_intention", + "typeSchema": "public", + "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 + }, + "vote_intention_consent_id": { + "name": "vote_intention_consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vote_intention_consent_content_hash": { + "name": "vote_intention_consent_content_hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "vote_intention_consented_at": { + "name": "vote_intention_consented_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "enum_supporter_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "consent_note": { + "name": "consent_note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "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": { + "supporter_contact_idx": { + "name": "supporter_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_nucleus_idx": { + "name": "supporter_nucleus_idx", + "columns": [ + { + "expression": "nucleus_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_vote_intention_idx": { + "name": "supporter_vote_intention_idx", + "columns": [ + { + "expression": "vote_intention", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_consent_idx": { + "name": "supporter_consent_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_vote_intention_consent_idx": { + "name": "supporter_vote_intention_consent_idx", + "columns": [ + { + "expression": "vote_intention_consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_source_idx": { + "name": "supporter_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_created_by_idx": { + "name": "supporter_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_updated_at_idx": { + "name": "supporter_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_created_at_idx": { + "name": "supporter_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "supporter_contact_id_contact_id_fk": { + "name": "supporter_contact_id_contact_id_fk", + "tableFrom": "supporter", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "supporter_nucleus_id_electoral_nucleus_id_fk": { + "name": "supporter_nucleus_id_electoral_nucleus_id_fk", + "tableFrom": "supporter", + "tableTo": "electoral_nucleus", + "columnsFrom": [ + "nucleus_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "supporter_consent_id_consent_id_fk": { + "name": "supporter_consent_id_consent_id_fk", + "tableFrom": "supporter", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "supporter_vote_intention_consent_id_consent_id_fk": { + "name": "supporter_vote_intention_consent_id_consent_id_fk", + "tableFrom": "supporter", + "tableTo": "consent", + "columnsFrom": [ + "vote_intention_consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "supporter_created_by_id_campaign_user_id_fk": { + "name": "supporter_created_by_id_campaign_user_id_fk", + "tableFrom": "supporter", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.supporter_import_batch": { + "name": "supporter_import_batch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "rows": { + "name": "rows", + "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": { + "supporter_import_batch_batch_id_idx": { + "name": "supporter_import_batch_batch_id_idx", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_import_batch_actor_idx": { + "name": "supporter_import_batch_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_import_batch_expires_at_idx": { + "name": "supporter_import_batch_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_import_batch_updated_at_idx": { + "name": "supporter_import_batch_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_import_batch_created_at_idx": { + "name": "supporter_import_batch_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "supporter_import_batch_actor_id_campaign_user_id_fk": { + "name": "supporter_import_batch_actor_id_campaign_user_id_fk", + "tableFrom": "supporter_import_batch", + "tableTo": "campaign_user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nucleus_update": { + "name": "nucleus_update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nucleus_id": { + "name": "nucleus_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "enum_nucleus_update_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'semanal'" + }, + "worked": { + "name": "worked", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "failed": { + "name": "failed", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "needs": { + "name": "needs", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "active_volunteers": { + "name": "active_volunteers", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "new_supports": { + "name": "new_supports", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "nucleus_update_nucleus_idx": { + "name": "nucleus_update_nucleus_idx", + "columns": [ + { + "expression": "nucleus_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "nucleus_update_author_idx": { + "name": "nucleus_update_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "nucleus_update_kind_idx": { + "name": "nucleus_update_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "nucleus_update_updated_at_idx": { + "name": "nucleus_update_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "nucleus_update_created_at_idx": { + "name": "nucleus_update_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "nucleus_update_nucleus_id_electoral_nucleus_id_fk": { + "name": "nucleus_update_nucleus_id_electoral_nucleus_id_fk", + "tableFrom": "nucleus_update", + "tableTo": "electoral_nucleus", + "columnsFrom": [ + "nucleus_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "nucleus_update_author_id_campaign_user_id_fk": { + "name": "nucleus_update_author_id_campaign_user_id_fk", + "tableFrom": "nucleus_update", + "tableTo": "campaign_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_plan_tasks": { + "name": "action_plan_tasks", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "responsible_id": { + "name": "responsible_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due": { + "name": "due", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "done_at": { + "name": "done_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "action_plan_tasks_order_idx": { + "name": "action_plan_tasks_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_tasks_parent_id_idx": { + "name": "action_plan_tasks_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_tasks_responsible_idx": { + "name": "action_plan_tasks_responsible_idx", + "columns": [ + { + "expression": "responsible_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "action_plan_tasks_responsible_id_contact_id_fk": { + "name": "action_plan_tasks_responsible_id_contact_id_fk", + "tableFrom": "action_plan_tasks", + "tableTo": "contact", + "columnsFrom": [ + "responsible_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "action_plan_tasks_parent_id_fk": { + "name": "action_plan_tasks_parent_id_fk", + "tableFrom": "action_plan_tasks", + "tableTo": "action_plan", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_plan_updates": { + "name": "action_plan_updates", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "body": { + "name": "body", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "action_plan_updates_order_idx": { + "name": "action_plan_updates_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_updates_parent_id_idx": { + "name": "action_plan_updates_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_updates_author_idx": { + "name": "action_plan_updates_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "action_plan_updates_author_id_campaign_user_id_fk": { + "name": "action_plan_updates_author_id_campaign_user_id_fk", + "tableFrom": "action_plan_updates", + "tableTo": "campaign_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "action_plan_updates_parent_id_fk": { + "name": "action_plan_updates_parent_id_fk", + "tableFrom": "action_plan_updates", + "tableTo": "action_plan", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_plan": { + "name": "action_plan", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "enum_action_plan_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "enum_action_plan_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rascunho'" + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "start_at": { + "name": "start_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "end_at": { + "name": "end_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "deadline": { + "name": "deadline", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "locality": { + "name": "locality", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "territory_notes": { + "name": "territory_notes", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "responsible_id": { + "name": "responsible_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "leadership_id": { + "name": "leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_plan_title_idx": { + "name": "action_plan_title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_slug_idx": { + "name": "action_plan_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_kind_idx": { + "name": "action_plan_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_status_idx": { + "name": "action_plan_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_start_at_idx": { + "name": "action_plan_start_at_idx", + "columns": [ + { + "expression": "start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_responsible_idx": { + "name": "action_plan_responsible_idx", + "columns": [ + { + "expression": "responsible_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_leadership_idx": { + "name": "action_plan_leadership_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_created_by_idx": { + "name": "action_plan_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_updated_at_idx": { + "name": "action_plan_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_created_at_idx": { + "name": "action_plan_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "action_plan_responsible_id_contact_id_fk": { + "name": "action_plan_responsible_id_contact_id_fk", + "tableFrom": "action_plan", + "tableTo": "contact", + "columnsFrom": [ + "responsible_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "action_plan_leadership_id_leadership_id_fk": { + "name": "action_plan_leadership_id_leadership_id_fk", + "tableFrom": "action_plan", + "tableTo": "leadership", + "columnsFrom": [ + "leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "action_plan_created_by_id_campaign_user_id_fk": { + "name": "action_plan_created_by_id_campaign_user_id_fk", + "tableFrom": "action_plan", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_plan_texts": { + "name": "action_plan_texts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "action_plan_texts_order_parent": { + "name": "action_plan_texts_order_parent", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_texts_text_idx": { + "name": "action_plan_texts_text_idx", + "columns": [ + { + "expression": "text", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "action_plan_texts_parent_fk": { + "name": "action_plan_texts_parent_fk", + "tableFrom": "action_plan_texts", + "tableTo": "action_plan", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_plan_rels": { + "name": "action_plan_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "action_plan_rels_order_idx": { + "name": "action_plan_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_rels_parent_idx": { + "name": "action_plan_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_rels_path_idx": { + "name": "action_plan_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_plan_rels_campaign_user_id_idx": { + "name": "action_plan_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "action_plan_rels_parent_fk": { + "name": "action_plan_rels_parent_fk", + "tableFrom": "action_plan_rels", + "tableTo": "action_plan", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "action_plan_rels_campaign_user_fk": { + "name": "action_plan_rels_campaign_user_fk", + "tableFrom": "action_plan_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.election_tally": { + "name": "election_tally", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "year": { + "name": "year", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "office": { + "name": "office", + "type": "enum_election_tally_office", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "enum_election_tally_turn", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'BA'" + }, + "city_code": { + "name": "city_code", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "city_name": { + "name": "city_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "zone_number": { + "name": "zone_number", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "aptos": { + "name": "aptos", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "comparecimento": { + "name": "comparecimento", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "abstencoes": { + "name": "abstencoes", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_validos": { + "name": "votos_validos", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_nominais_validos": { + "name": "votos_nominais_validos", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_legenda": { + "name": "votos_legenda", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_branco": { + "name": "votos_branco", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_nulo": { + "name": "votos_nulo", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_anulados": { + "name": "votos_anulados", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "winner_candidate_number": { + "name": "winner_candidate_number", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "winner_candidate_name": { + "name": "winner_candidate_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "winner_votes": { + "name": "winner_votes", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "winner_coalition": { + "name": "winner_coalition", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "winner_party": { + "name": "winner_party", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "election_tally_year_idx": { + "name": "election_tally_year_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_office_idx": { + "name": "election_tally_office_idx", + "columns": [ + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_turn_idx": { + "name": "election_tally_turn_idx", + "columns": [ + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_state_idx": { + "name": "election_tally_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_city_code_idx": { + "name": "election_tally_city_code_idx", + "columns": [ + { + "expression": "city_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_city_name_idx": { + "name": "election_tally_city_name_idx", + "columns": [ + { + "expression": "city_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_zone_number_idx": { + "name": "election_tally_zone_number_idx", + "columns": [ + { + "expression": "zone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_updated_at_idx": { + "name": "election_tally_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_created_at_idx": { + "name": "election_tally_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "year_office_turn_state_cityCode_zoneNumber_idx": { + "name": "year_office_turn_state_cityCode_zoneNumber_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "city_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.election_candidate_vote": { + "name": "election_candidate_vote", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "year": { + "name": "year", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "office": { + "name": "office", + "type": "enum_election_candidate_vote_office", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "enum_election_candidate_vote_turn", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'BA'" + }, + "city_code": { + "name": "city_code", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "city_name": { + "name": "city_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "zone_number": { + "name": "zone_number", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "candidate_number": { + "name": "candidate_number", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "candidate_name": { + "name": "candidate_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "coalition": { + "name": "coalition", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "party": { + "name": "party", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "vote_type": { + "name": "vote_type", + "type": "enum_election_candidate_vote_vote_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'nominal'" + }, + "votes": { + "name": "votes", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "election_candidate_vote_year_idx": { + "name": "election_candidate_vote_year_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_office_idx": { + "name": "election_candidate_vote_office_idx", + "columns": [ + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_turn_idx": { + "name": "election_candidate_vote_turn_idx", + "columns": [ + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_state_idx": { + "name": "election_candidate_vote_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_city_code_idx": { + "name": "election_candidate_vote_city_code_idx", + "columns": [ + { + "expression": "city_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_city_name_idx": { + "name": "election_candidate_vote_city_name_idx", + "columns": [ + { + "expression": "city_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_zone_number_idx": { + "name": "election_candidate_vote_zone_number_idx", + "columns": [ + { + "expression": "zone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_candidate_number_idx": { + "name": "election_candidate_vote_candidate_number_idx", + "columns": [ + { + "expression": "candidate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_vote_type_idx": { + "name": "election_candidate_vote_vote_type_idx", + "columns": [ + { + "expression": "vote_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_updated_at_idx": { + "name": "election_candidate_vote_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_created_at_idx": { + "name": "election_candidate_vote_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compound_index_idx": { + "name": "compound_index_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "city_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "candidate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vote_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.election_candidate": { + "name": "election_candidate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "year": { + "name": "year", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "office": { + "name": "office", + "type": "enum_election_candidate_office", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "enum_election_candidate_turn", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'BA'" + }, + "candidate_number": { + "name": "candidate_number", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "urna_name": { + "name": "urna_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "complete_name": { + "name": "complete_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "party": { + "name": "party", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "coalition": { + "name": "coalition", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "candidate_status": { + "name": "candidate_status", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "elected": { + "name": "elected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "elected_by": { + "name": "elected_by", + "type": "enum_election_candidate_elected_by", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "total_votes_state": { + "name": "total_votes_state", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "identity_key": { + "name": "identity_key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "running_again2026": { + "name": "running_again2026", + "type": "enum_election_candidate_running_again2026", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'desconhecido'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "election_candidate_year_idx": { + "name": "election_candidate_year_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_office_idx": { + "name": "election_candidate_office_idx", + "columns": [ + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_turn_idx": { + "name": "election_candidate_turn_idx", + "columns": [ + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_state_idx": { + "name": "election_candidate_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_candidate_number_idx": { + "name": "election_candidate_candidate_number_idx", + "columns": [ + { + "expression": "candidate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_elected_idx": { + "name": "election_candidate_elected_idx", + "columns": [ + { + "expression": "elected", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_identity_key_idx": { + "name": "election_candidate_identity_key_idx", + "columns": [ + { + "expression": "identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_updated_at_idx": { + "name": "election_candidate_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_created_at_idx": { + "name": "election_candidate_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "year_office_turn_state_candidateNumber_idx": { + "name": "year_office_turn_state_candidateNumber_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "candidate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "media_updated_at_idx": { + "name": "media_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_created_at_idx": { + "name": "media_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_filename_idx": { + "name": "media_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.petition": { + "name": "petition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "subtitle": { + "name": "subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "form_title": { + "name": "form_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "form_subtitle": { + "name": "form_subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "form_consent_id": { + "name": "form_consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "_status": { + "name": "_status", + "type": "enum_petition_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + } + }, + "indexes": { + "petition_form_form_consent_idx": { + "name": "petition_form_form_consent_idx", + "columns": [ + { + "expression": "form_consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "petition_updated_at_idx": { + "name": "petition_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "petition_created_at_idx": { + "name": "petition_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "petition__status_idx": { + "name": "petition__status_idx", + "columns": [ + { + "expression": "_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "petition_form_consent_id_consent_id_fk": { + "name": "petition_form_consent_id_consent_id_fk", + "tableFrom": "petition", + "tableTo": "consent", + "columnsFrom": [ + "form_consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._petition_v": { + "name": "_petition_v", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_title": { + "name": "version_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_subtitle": { + "name": "version_subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_enabled": { + "name": "version_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "version_body": { + "name": "version_body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version_form_title": { + "name": "version_form_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_form_subtitle": { + "name": "version_form_subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_form_consent_id": { + "name": "version_form_consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_updated_at": { + "name": "version_updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version__status": { + "name": "version__status", + "type": "enum__petition_v_version_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latest": { + "name": "latest", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_petition_v_parent_idx": { + "name": "_petition_v_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_version_form_version_form_consent_idx": { + "name": "_petition_v_version_form_version_form_consent_idx", + "columns": [ + { + "expression": "version_form_consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_version_version_updated_at_idx": { + "name": "_petition_v_version_version_updated_at_idx", + "columns": [ + { + "expression": "version_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_version_version_created_at_idx": { + "name": "_petition_v_version_version_created_at_idx", + "columns": [ + { + "expression": "version_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_version_version__status_idx": { + "name": "_petition_v_version_version__status_idx", + "columns": [ + { + "expression": "version__status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_created_at_idx": { + "name": "_petition_v_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_updated_at_idx": { + "name": "_petition_v_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_latest_idx": { + "name": "_petition_v_latest_idx", + "columns": [ + { + "expression": "latest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_petition_v_parent_id_petition_id_fk": { + "name": "_petition_v_parent_id_petition_id_fk", + "tableFrom": "_petition_v", + "tableTo": "petition", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_petition_v_version_form_consent_id_consent_id_fk": { + "name": "_petition_v_version_form_consent_id_consent_id_fk", + "tableFrom": "_petition_v", + "tableTo": "consent", + "columnsFrom": [ + "version_form_consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact": { + "name": "contact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "gender": { + "name": "gender", + "type": "enum_contact_gender", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "enum_contact_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contact_phone_idx": { + "name": "contact_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_updated_at_idx": { + "name": "contact_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_created_at_idx": { + "name": "contact_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.consent": { + "name": "consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "consent_key_idx": { + "name": "consent_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "consent_updated_at_idx": { + "name": "consent_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "consent_created_at_idx": { + "name": "consent_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signature": { + "name": "signature", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "petition_id": { + "name": "petition_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signature_contact_idx": { + "name": "signature_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signature_petition_idx": { + "name": "signature_petition_idx", + "columns": [ + { + "expression": "petition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signature_consent_idx": { + "name": "signature_consent_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signature_updated_at_idx": { + "name": "signature_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signature_created_at_idx": { + "name": "signature_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signature_contact_id_contact_id_fk": { + "name": "signature_contact_id_contact_id_fk", + "tableFrom": "signature", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "signature_petition_id_petition_id_fk": { + "name": "signature_petition_id_petition_id_fk", + "tableFrom": "signature", + "tableTo": "petition", + "columnsFrom": [ + "petition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "signature_consent_id_consent_id_fk": { + "name": "signature_consent_id_consent_id_fk", + "tableFrom": "signature", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscription_contact_idx": { + "name": "subscription_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_consent_idx": { + "name": "subscription_consent_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_updated_at_idx": { + "name": "subscription_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_created_at_idx": { + "name": "subscription_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_contact_id_contact_id_fk": { + "name": "subscription_contact_id_contact_id_fk", + "tableFrom": "subscription", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "subscription_consent_id_consent_id_fk": { + "name": "subscription_consent_id_consent_id_fk", + "tableFrom": "subscription", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "enum_post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "subtitle": { + "name": "subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "cover_image_id": { + "name": "cover_image_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "published_date": { + "name": "published_date", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "_status": { + "name": "_status", + "type": "enum_post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + } + }, + "indexes": { + "post_slug_idx": { + "name": "post_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_category_idx": { + "name": "post_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_cover_image_idx": { + "name": "post_cover_image_idx", + "columns": [ + { + "expression": "cover_image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_updated_at_idx": { + "name": "post_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_created_at_idx": { + "name": "post_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post__status_idx": { + "name": "post__status_idx", + "columns": [ + { + "expression": "_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_category_id_tag_id_fk": { + "name": "post_category_id_tag_id_fk", + "tableFrom": "post", + "tableTo": "tag", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_cover_image_id_media_id_fk": { + "name": "post_cover_image_id_media_id_fk", + "tableFrom": "post", + "tableTo": "media", + "columnsFrom": [ + "cover_image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_rels": { + "name": "post_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "post_rels_order_idx": { + "name": "post_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_rels_parent_idx": { + "name": "post_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_rels_path_idx": { + "name": "post_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_rels_tag_id_idx": { + "name": "post_rels_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_rels_parent_fk": { + "name": "post_rels_parent_fk", + "tableFrom": "post_rels", + "tableTo": "post", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_rels_tag_fk": { + "name": "post_rels_tag_fk", + "tableFrom": "post_rels", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._post_v": { + "name": "_post_v", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_title": { + "name": "version_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_slug": { + "name": "version_slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_type": { + "name": "version_type", + "type": "enum__post_v_version_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "version_category_id": { + "name": "version_category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_subtitle": { + "name": "version_subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_cover_image_id": { + "name": "version_cover_image_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_published_date": { + "name": "version_published_date", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_body": { + "name": "version_body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version_updated_at": { + "name": "version_updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version__status": { + "name": "version__status", + "type": "enum__post_v_version_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latest": { + "name": "latest", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_post_v_parent_idx": { + "name": "_post_v_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_slug_idx": { + "name": "_post_v_version_version_slug_idx", + "columns": [ + { + "expression": "version_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_category_idx": { + "name": "_post_v_version_version_category_idx", + "columns": [ + { + "expression": "version_category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_cover_image_idx": { + "name": "_post_v_version_version_cover_image_idx", + "columns": [ + { + "expression": "version_cover_image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_updated_at_idx": { + "name": "_post_v_version_version_updated_at_idx", + "columns": [ + { + "expression": "version_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_created_at_idx": { + "name": "_post_v_version_version_created_at_idx", + "columns": [ + { + "expression": "version_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version__status_idx": { + "name": "_post_v_version_version__status_idx", + "columns": [ + { + "expression": "version__status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_created_at_idx": { + "name": "_post_v_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_updated_at_idx": { + "name": "_post_v_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_latest_idx": { + "name": "_post_v_latest_idx", + "columns": [ + { + "expression": "latest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_post_v_parent_id_post_id_fk": { + "name": "_post_v_parent_id_post_id_fk", + "tableFrom": "_post_v", + "tableTo": "post", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_post_v_version_category_id_tag_id_fk": { + "name": "_post_v_version_category_id_tag_id_fk", + "tableFrom": "_post_v", + "tableTo": "tag", + "columnsFrom": [ + "version_category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_post_v_version_cover_image_id_media_id_fk": { + "name": "_post_v_version_cover_image_id_media_id_fk", + "tableFrom": "_post_v", + "tableTo": "media", + "columnsFrom": [ + "version_cover_image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._post_v_rels": { + "name": "_post_v_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_post_v_rels_order_idx": { + "name": "_post_v_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_rels_parent_idx": { + "name": "_post_v_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_rels_path_idx": { + "name": "_post_v_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_rels_tag_id_idx": { + "name": "_post_v_rels_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_post_v_rels_parent_fk": { + "name": "_post_v_rels_parent_fk", + "tableFrom": "_post_v_rels", + "tableTo": "_post_v", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "_post_v_rels_tag_fk": { + "name": "_post_v_rels_tag_fk", + "tableFrom": "_post_v_rels", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "hidden": { + "name": "hidden", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tag_slug_idx": { + "name": "tag_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tag_updated_at_idx": { + "name": "tag_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tag_created_at_idx": { + "name": "tag_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_kv": { + "name": "payload_kv", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payload_kv_key_idx": { + "name": "payload_kv_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_jobs_log": { + "name": "payload_jobs_log", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "task_slug": { + "name": "task_slug", + "type": "enum_payload_jobs_log_task_slug", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "task_i_d": { + "name": "task_i_d", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "enum_payload_jobs_log_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_jobs_log_order_idx": { + "name": "payload_jobs_log_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_log_parent_id_idx": { + "name": "payload_jobs_log_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_jobs_log_parent_id_fk": { + "name": "payload_jobs_log_parent_id_fk", + "tableFrom": "payload_jobs_log", + "tableTo": "payload_jobs", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_jobs": { + "name": "payload_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "total_tried": { + "name": "total_tried", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "has_error": { + "name": "has_error", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "task_slug": { + "name": "task_slug", + "type": "enum_payload_jobs_task_slug", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "queue": { + "name": "queue", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "wait_until": { + "name": "wait_until", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "processing": { + "name": "processing", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_jobs_completed_at_idx": { + "name": "payload_jobs_completed_at_idx", + "columns": [ + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_total_tried_idx": { + "name": "payload_jobs_total_tried_idx", + "columns": [ + { + "expression": "total_tried", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_has_error_idx": { + "name": "payload_jobs_has_error_idx", + "columns": [ + { + "expression": "has_error", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_task_slug_idx": { + "name": "payload_jobs_task_slug_idx", + "columns": [ + { + "expression": "task_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_queue_idx": { + "name": "payload_jobs_queue_idx", + "columns": [ + { + "expression": "queue", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_wait_until_idx": { + "name": "payload_jobs_wait_until_idx", + "columns": [ + { + "expression": "wait_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_processing_idx": { + "name": "payload_jobs_processing_idx", + "columns": [ + { + "expression": "processing", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_updated_at_idx": { + "name": "payload_jobs_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_created_at_idx": { + "name": "payload_jobs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_locked_documents": { + "name": "payload_locked_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "global_slug": { + "name": "global_slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_locked_documents_global_slug_idx": { + "name": "payload_locked_documents_global_slug_idx", + "columns": [ + { + "expression": "global_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_updated_at_idx": { + "name": "payload_locked_documents_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_created_at_idx": { + "name": "payload_locked_documents_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_locked_documents_rels": { + "name": "payload_locked_documents_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "users_id": { + "name": "users_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_invite_id": { + "name": "campaign_invite_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "electoral_nucleus_id": { + "name": "electoral_nucleus_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "leadership_id": { + "name": "leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "supporter_id": { + "name": "supporter_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "supporter_import_batch_id": { + "name": "supporter_import_batch_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "nucleus_update_id": { + "name": "nucleus_update_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "action_plan_id": { + "name": "action_plan_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "election_tally_id": { + "name": "election_tally_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "election_candidate_vote_id": { + "name": "election_candidate_vote_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "election_candidate_id": { + "name": "election_candidate_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "media_id": { + "name": "media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "petition_id": { + "name": "petition_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signature_id": { + "name": "signature_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_locked_documents_rels_order_idx": { + "name": "payload_locked_documents_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_parent_idx": { + "name": "payload_locked_documents_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_path_idx": { + "name": "payload_locked_documents_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_users_id_idx": { + "name": "payload_locked_documents_rels_users_id_idx", + "columns": [ + { + "expression": "users_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_campaign_user_id_idx": { + "name": "payload_locked_documents_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_campaign_invite_id_idx": { + "name": "payload_locked_documents_rels_campaign_invite_id_idx", + "columns": [ + { + "expression": "campaign_invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_electoral_nucleus_id_idx": { + "name": "payload_locked_documents_rels_electoral_nucleus_id_idx", + "columns": [ + { + "expression": "electoral_nucleus_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_leadership_id_idx": { + "name": "payload_locked_documents_rels_leadership_id_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_supporter_id_idx": { + "name": "payload_locked_documents_rels_supporter_id_idx", + "columns": [ + { + "expression": "supporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_supporter_import_batch_id_idx": { + "name": "payload_locked_documents_rels_supporter_import_batch_id_idx", + "columns": [ + { + "expression": "supporter_import_batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_nucleus_update_id_idx": { + "name": "payload_locked_documents_rels_nucleus_update_id_idx", + "columns": [ + { + "expression": "nucleus_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_action_plan_id_idx": { + "name": "payload_locked_documents_rels_action_plan_id_idx", + "columns": [ + { + "expression": "action_plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_election_tally_id_idx": { + "name": "payload_locked_documents_rels_election_tally_id_idx", + "columns": [ + { + "expression": "election_tally_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_election_candidate_vote_id_idx": { + "name": "payload_locked_documents_rels_election_candidate_vote_id_idx", + "columns": [ + { + "expression": "election_candidate_vote_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_election_candidate_id_idx": { + "name": "payload_locked_documents_rels_election_candidate_id_idx", + "columns": [ + { + "expression": "election_candidate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_media_id_idx": { + "name": "payload_locked_documents_rels_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_petition_id_idx": { + "name": "payload_locked_documents_rels_petition_id_idx", + "columns": [ + { + "expression": "petition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_contact_id_idx": { + "name": "payload_locked_documents_rels_contact_id_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_consent_id_idx": { + "name": "payload_locked_documents_rels_consent_id_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_signature_id_idx": { + "name": "payload_locked_documents_rels_signature_id_idx", + "columns": [ + { + "expression": "signature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_subscription_id_idx": { + "name": "payload_locked_documents_rels_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_post_id_idx": { + "name": "payload_locked_documents_rels_post_id_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_tag_id_idx": { + "name": "payload_locked_documents_rels_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_locked_documents_rels_parent_fk": { + "name": "payload_locked_documents_rels_parent_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "payload_locked_documents", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_users_fk": { + "name": "payload_locked_documents_rels_users_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "users", + "columnsFrom": [ + "users_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_campaign_user_fk": { + "name": "payload_locked_documents_rels_campaign_user_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_campaign_invite_fk": { + "name": "payload_locked_documents_rels_campaign_invite_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "campaign_invite", + "columnsFrom": [ + "campaign_invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_electoral_nucleus_fk": { + "name": "payload_locked_documents_rels_electoral_nucleus_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "electoral_nucleus", + "columnsFrom": [ + "electoral_nucleus_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_leadership_fk": { + "name": "payload_locked_documents_rels_leadership_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "leadership", + "columnsFrom": [ + "leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_supporter_fk": { + "name": "payload_locked_documents_rels_supporter_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "supporter", + "columnsFrom": [ + "supporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_supporter_import_batch_fk": { + "name": "payload_locked_documents_rels_supporter_import_batch_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "supporter_import_batch", + "columnsFrom": [ + "supporter_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_nucleus_update_fk": { + "name": "payload_locked_documents_rels_nucleus_update_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "nucleus_update", + "columnsFrom": [ + "nucleus_update_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_action_plan_fk": { + "name": "payload_locked_documents_rels_action_plan_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "action_plan", + "columnsFrom": [ + "action_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_election_tally_fk": { + "name": "payload_locked_documents_rels_election_tally_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "election_tally", + "columnsFrom": [ + "election_tally_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_election_candidate_vote_fk": { + "name": "payload_locked_documents_rels_election_candidate_vote_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "election_candidate_vote", + "columnsFrom": [ + "election_candidate_vote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_election_candidate_fk": { + "name": "payload_locked_documents_rels_election_candidate_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "election_candidate", + "columnsFrom": [ + "election_candidate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_media_fk": { + "name": "payload_locked_documents_rels_media_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_petition_fk": { + "name": "payload_locked_documents_rels_petition_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "petition", + "columnsFrom": [ + "petition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_contact_fk": { + "name": "payload_locked_documents_rels_contact_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_consent_fk": { + "name": "payload_locked_documents_rels_consent_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_signature_fk": { + "name": "payload_locked_documents_rels_signature_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "signature", + "columnsFrom": [ + "signature_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_subscription_fk": { + "name": "payload_locked_documents_rels_subscription_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "subscription", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_post_fk": { + "name": "payload_locked_documents_rels_post_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_tag_fk": { + "name": "payload_locked_documents_rels_tag_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_preferences": { + "name": "payload_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_preferences_key_idx": { + "name": "payload_preferences_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_updated_at_idx": { + "name": "payload_preferences_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_created_at_idx": { + "name": "payload_preferences_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_preferences_rels": { + "name": "payload_preferences_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "users_id": { + "name": "users_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_preferences_rels_order_idx": { + "name": "payload_preferences_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_parent_idx": { + "name": "payload_preferences_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_path_idx": { + "name": "payload_preferences_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_users_id_idx": { + "name": "payload_preferences_rels_users_id_idx", + "columns": [ + { + "expression": "users_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_campaign_user_id_idx": { + "name": "payload_preferences_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_preferences_rels_parent_fk": { + "name": "payload_preferences_rels_parent_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "payload_preferences", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_preferences_rels_users_fk": { + "name": "payload_preferences_rels_users_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "users", + "columnsFrom": [ + "users_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_preferences_rels_campaign_user_fk": { + "name": "payload_preferences_rels_campaign_user_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_migrations": { + "name": "payload_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "batch": { + "name": "batch", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_migrations_updated_at_idx": { + "name": "payload_migrations_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_migrations_created_at_idx": { + "name": "payload_migrations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings_social_links": { + "name": "site_settings_social_links", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "enum_site_settings_social_links_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "site_settings_social_links_order_idx": { + "name": "site_settings_social_links_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "site_settings_social_links_parent_id_idx": { + "name": "site_settings_social_links_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "site_settings_social_links_parent_id_fk": { + "name": "site_settings_social_links_parent_id_fk", + "tableFrom": "site_settings_social_links", + "tableTo": "site_settings", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "header_title": { + "name": "header_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "_status": { + "name": "_status", + "type": "enum_site_settings_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "site_settings__status_idx": { + "name": "site_settings__status_idx", + "columns": [ + { + "expression": "_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._site_settings_v_version_social_links": { + "name": "_site_settings_v_version_social_links", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "enum__site_settings_v_version_social_links_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "_uuid": { + "name": "_uuid", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_site_settings_v_version_social_links_order_idx": { + "name": "_site_settings_v_version_social_links_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_version_social_links_parent_id_idx": { + "name": "_site_settings_v_version_social_links_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_site_settings_v_version_social_links_parent_id_fk": { + "name": "_site_settings_v_version_social_links_parent_id_fk", + "tableFrom": "_site_settings_v_version_social_links", + "tableTo": "_site_settings_v", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._site_settings_v": { + "name": "_site_settings_v", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "version_header_title": { + "name": "version_header_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version__status": { + "name": "version__status", + "type": "enum__site_settings_v_version_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "version_updated_at": { + "name": "version_updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latest": { + "name": "latest", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "autosave": { + "name": "autosave", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_site_settings_v_version_version__status_idx": { + "name": "_site_settings_v_version_version__status_idx", + "columns": [ + { + "expression": "version__status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_created_at_idx": { + "name": "_site_settings_v_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_updated_at_idx": { + "name": "_site_settings_v_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_latest_idx": { + "name": "_site_settings_v_latest_idx", + "columns": [ + { + "expression": "latest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_autosave_idx": { + "name": "_site_settings_v_autosave_idx", + "columns": [ + { + "expression": "autosave", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.home": { + "name": "home", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "image_id": { + "name": "image_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "home_image_idx": { + "name": "home_image_idx", + "columns": [ + { + "expression": "image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "home_image_id_media_id_fk": { + "name": "home_image_id_media_id_fk", + "tableFrom": "home", + "tableTo": "media", + "columnsFrom": [ + "image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.metadata_keywords": { + "name": "metadata_keywords", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "metadata_keywords_order_idx": { + "name": "metadata_keywords_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "metadata_keywords_parent_id_idx": { + "name": "metadata_keywords_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "metadata_keywords_parent_id_fk": { + "name": "metadata_keywords_parent_id_fk", + "tableFrom": "metadata_keywords", + "tableTo": "metadata", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "open_graph_site_name": { + "name": "open_graph_site_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "open_graph_description": { + "name": "open_graph_description", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "twitter_creator": { + "name": "twitter_creator", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "twitter_description": { + "name": "twitter_description", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "image_id": { + "name": "image_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "metadata_image_idx": { + "name": "metadata_image_idx", + "columns": [ + { + "expression": "image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "metadata_image_id_media_id_fk": { + "name": "metadata_image_id_media_id_fk", + "tableFrom": "metadata", + "tableTo": "media", + "columnsFrom": [ + "image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.enum_campaign_user_role": { + "name": "enum_campaign_user_role", + "schema": "public", + "values": [ + "geral", + "coordenador", + "lideranca" + ] + }, + "public.enum_campaign_invite_kind": { + "name": "enum_campaign_invite_kind", + "schema": "public", + "values": [ + "login", + "autopreenchimento" + ] + }, + "public.enum_electoral_nucleus_status": { + "name": "enum_electoral_nucleus_status", + "schema": "public", + "values": [ + "ativo", + "arquivado" + ] + }, + "public.enum_electoral_nucleus_organization_kind": { + "name": "enum_electoral_nucleus_organization_kind", + "schema": "public", + "values": [ + "territorial", + "associacao", + "sindicato", + "religioso", + "movimento", + "categoria_profissional", + "outro" + ] + }, + "public.enum_electoral_nucleus_sector_kind": { + "name": "enum_electoral_nucleus_sector_kind", + "schema": "public", + "values": [ + "rural", + "religioso", + "sindical", + "empresarial", + "juventude", + "saude", + "educacao", + "cultura", + "outro" + ] + }, + "public.enum_leadership_sector": { + "name": "enum_leadership_sector", + "schema": "public", + "values": [ + "religioso", + "sindical", + "comunitario", + "rural", + "empresarial", + "juventude", + "saude", + "educacao", + "cultura", + "outro" + ] + }, + "public.enum_leadership_support_status": { + "name": "enum_leadership_support_status", + "schema": "public", + "values": [ + "engajado", + "a_abordar", + "em_disputa", + "negativo" + ] + }, + "public.enum_supporter_vote_intention": { + "name": "enum_supporter_vote_intention", + "schema": "public", + "values": [ + "certo", + "tende_a_certo", + "indeciso", + "outro" + ] + }, + "public.enum_supporter_source": { + "name": "enum_supporter_source", + "schema": "public", + "values": [ + "import_csv", + "manual", + "convite", + "evento" + ] + }, + "public.enum_nucleus_update_kind": { + "name": "enum_nucleus_update_kind", + "schema": "public", + "values": [ + "semanal", + "urgente", + "nota" + ] + }, + "public.enum_action_plan_kind": { + "name": "enum_action_plan_kind", + "schema": "public", + "values": [ + "caminhada", + "comicio", + "carreata", + "panfletagem", + "porta_a_porta", + "reuniao_apoio", + "lancamento", + "convencao", + "ato", + "entrevista", + "producao_conteudo", + "digital", + "outro" + ] + }, + "public.enum_action_plan_status": { + "name": "enum_action_plan_status", + "schema": "public", + "values": [ + "rascunho", + "planejado", + "confirmado", + "realizado", + "cancelado" + ] + }, + "public.enum_election_tally_office": { + "name": "enum_election_tally_office", + "schema": "public", + "values": [ + "presidente", + "governador", + "deputado_federal", + "deputado_estadual" + ] + }, + "public.enum_election_tally_turn": { + "name": "enum_election_tally_turn", + "schema": "public", + "values": [ + "1", + "2" + ] + }, + "public.enum_election_candidate_vote_office": { + "name": "enum_election_candidate_vote_office", + "schema": "public", + "values": [ + "presidente", + "governador", + "deputado_federal", + "deputado_estadual" + ] + }, + "public.enum_election_candidate_vote_turn": { + "name": "enum_election_candidate_vote_turn", + "schema": "public", + "values": [ + "1", + "2" + ] + }, + "public.enum_election_candidate_vote_vote_type": { + "name": "enum_election_candidate_vote_vote_type", + "schema": "public", + "values": [ + "nominal", + "legenda" + ] + }, + "public.enum_election_candidate_office": { + "name": "enum_election_candidate_office", + "schema": "public", + "values": [ + "presidente", + "governador", + "deputado_federal", + "deputado_estadual" + ] + }, + "public.enum_election_candidate_turn": { + "name": "enum_election_candidate_turn", + "schema": "public", + "values": [ + "1", + "2" + ] + }, + "public.enum_election_candidate_elected_by": { + "name": "enum_election_candidate_elected_by", + "schema": "public", + "values": [ + "QP", + "média", + "2º turno" + ] + }, + "public.enum_election_candidate_running_again2026": { + "name": "enum_election_candidate_running_again2026", + "schema": "public", + "values": [ + "sim", + "nao", + "desconhecido" + ] + }, + "public.enum_petition_status": { + "name": "enum_petition_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum__petition_v_version_status": { + "name": "enum__petition_v_version_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum_contact_gender": { + "name": "enum_contact_gender", + "schema": "public", + "values": [ + "feminino", + "masculino", + "outro", + "nao_informado" + ] + }, + "public.enum_contact_state": { + "name": "enum_contact_state", + "schema": "public", + "values": [ + "AC", + "AL", + "AM", + "AP", + "BA", + "CE", + "DF", + "ES", + "GO", + "MA", + "MG", + "MS", + "MT", + "PA", + "PB", + "PE", + "PI", + "PR", + "RJ", + "RN", + "RO", + "RR", + "RS", + "SC", + "SE", + "SP", + "TO" + ] + }, + "public.enum_post_type": { + "name": "enum_post_type", + "schema": "public", + "values": [ + "noticia", + "campanha", + "artigo", + "evento" + ] + }, + "public.enum_post_status": { + "name": "enum_post_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum__post_v_version_type": { + "name": "enum__post_v_version_type", + "schema": "public", + "values": [ + "noticia", + "campanha", + "artigo", + "evento" + ] + }, + "public.enum__post_v_version_status": { + "name": "enum__post_v_version_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum_payload_jobs_log_task_slug": { + "name": "enum_payload_jobs_log_task_slug", + "schema": "public", + "values": [ + "inline", + "schedulePublish" + ] + }, + "public.enum_payload_jobs_log_state": { + "name": "enum_payload_jobs_log_state", + "schema": "public", + "values": [ + "failed", + "succeeded" + ] + }, + "public.enum_payload_jobs_task_slug": { + "name": "enum_payload_jobs_task_slug", + "schema": "public", + "values": [ + "inline", + "schedulePublish" + ] + }, + "public.enum_site_settings_social_links_platform": { + "name": "enum_site_settings_social_links_platform", + "schema": "public", + "values": [ + "instagram", + "facebook", + "youtube", + "whatsapp" + ] + }, + "public.enum_site_settings_status": { + "name": "enum_site_settings_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum__site_settings_v_version_social_links_platform": { + "name": "enum__site_settings_v_version_social_links_platform", + "schema": "public", + "values": [ + "instagram", + "facebook", + "youtube", + "whatsapp" + ] + }, + "public.enum__site_settings_v_version_status": { + "name": "enum__site_settings_v_version_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "id": "d77de497-7374-4455-9fd9-789a471576fc", + "prevId": "00000000-0000-0000-0000-000000000000" +} \ No newline at end of file diff --git a/src/migrations/20260719_011015_add_supporter_import_batch.ts b/src/migrations/20260719_011015_add_supporter_import_batch.ts new file mode 100644 index 00000000..7d033519 --- /dev/null +++ b/src/migrations/20260719_011015_add_supporter_import_batch.ts @@ -0,0 +1,77 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Adds the `supporterImportBatch` staging collection (roadmap C6, Phase 5): + * short-lived server-side storage for the supporter CSV import wizard so the + * full ok-row set never crosses the Server Action boundary twice. + * + * Hand-trimmed from the Payload-generated diff: the generator also tried to + * recreate the `supporter` table, its enums, FKs, indexes, and the + * `payload_locked_documents_rels.supporter_id` column — all of which already + * exist via `20260718_222656_add_supporter`. Those drift statements were + * removed so this migration is idempotent against databases that already ran + * the supporter migration. + */ +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "supporter_import_batch" ( + "id" serial PRIMARY KEY NOT NULL, + "batch_id" varchar NOT NULL, + "actor_id" integer NOT NULL, + "expires_at" timestamp(3) with time zone NOT NULL, + "rows" jsonb NOT NULL, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "supporter_import_batch_id" integer; + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'supporter_import_batch_actor_id_campaign_user_id_fk' + ) THEN + ALTER TABLE "supporter_import_batch" + ADD CONSTRAINT "supporter_import_batch_actor_id_campaign_user_id_fk" + FOREIGN KEY ("actor_id") REFERENCES "public"."campaign_user"("id") + ON DELETE set null ON UPDATE no action; + END IF; + END $$; + + CREATE UNIQUE INDEX IF NOT EXISTS "supporter_import_batch_batch_id_idx" + ON "supporter_import_batch" USING btree ("batch_id"); + CREATE INDEX IF NOT EXISTS "supporter_import_batch_actor_idx" + ON "supporter_import_batch" USING btree ("actor_id"); + CREATE INDEX IF NOT EXISTS "supporter_import_batch_expires_at_idx" + ON "supporter_import_batch" USING btree ("expires_at"); + CREATE INDEX IF NOT EXISTS "supporter_import_batch_updated_at_idx" + ON "supporter_import_batch" USING btree ("updated_at"); + CREATE INDEX IF NOT EXISTS "supporter_import_batch_created_at_idx" + ON "supporter_import_batch" USING btree ("created_at"); + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'payload_locked_documents_rels_supporter_import_batch_fk' + ) THEN + ALTER TABLE "payload_locked_documents_rels" + ADD CONSTRAINT "payload_locked_documents_rels_supporter_import_batch_fk" + FOREIGN KEY ("supporter_import_batch_id") REFERENCES "public"."supporter_import_batch"("id") + ON DELETE cascade ON UPDATE no action; + END IF; + END $$; + + CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_supporter_import_batch_id_idx" + ON "payload_locked_documents_rels" USING btree ("supporter_import_batch_id"); + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql` + ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_supporter_import_batch_fk"; + DROP INDEX IF EXISTS "payload_locked_documents_rels_supporter_import_batch_id_idx"; + ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "supporter_import_batch_id"; + + DROP TABLE IF EXISTS "supporter_import_batch" CASCADE; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 332db1fa..c1f0f7be 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -8,6 +8,7 @@ import * as migration_20260718_190559_territorio_multi_municipio_bairro from './ import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results'; import * as migration_20260718_222656_add_supporter from './20260718_222656_add_supporter'; import * as migration_20260718_222832_add_action_plan from './20260718_222832_add_action_plan'; +import * as migration_20260719_011015_add_supporter_import_batch from './20260719_011015_add_supporter_import_batch'; export const migrations = [ { @@ -60,4 +61,9 @@ export const migrations = [ down: migration_20260718_222832_add_action_plan.down, name: '20260718_222832_add_action_plan', }, + { + up: migration_20260719_011015_add_supporter_import_batch.up, + down: migration_20260719_011015_add_supporter_import_batch.down, + name: '20260719_011015_add_supporter_import_batch' + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 241e7673..701ed9bf 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -74,6 +74,7 @@ export interface Config { electoralNucleus: ElectoralNucleus; leadership: Leadership; supporter: Supporter; + supporterImportBatch: SupporterImportBatch; nucleusUpdate: NucleusUpdate; actionPlan: ActionPlan; electionTally: ElectionTally; @@ -101,6 +102,7 @@ export interface Config { electoralNucleus: ElectoralNucleusSelect | ElectoralNucleusSelect; leadership: LeadershipSelect | LeadershipSelect; supporter: SupporterSelect | SupporterSelect; + supporterImportBatch: SupporterImportBatchSelect | SupporterImportBatchSelect; nucleusUpdate: NucleusUpdateSelect | NucleusUpdateSelect; actionPlan: ActionPlanSelect | ActionPlanSelect; electionTally: ElectionTallySelect | ElectionTallySelect; @@ -475,6 +477,27 @@ export interface Supporter { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supporterImportBatch". + */ +export interface SupporterImportBatch { + id: number; + batchId: string; + actor: number | CampaignUser; + expiresAt: string; + rows: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "nucleusUpdate". @@ -909,6 +932,10 @@ export interface PayloadLockedDocument { relationTo: 'supporter'; value: number | Supporter; } | null) + | ({ + relationTo: 'supporterImportBatch'; + value: number | SupporterImportBatch; + } | null) | ({ relationTo: 'nucleusUpdate'; value: number | NucleusUpdate; @@ -1186,6 +1213,18 @@ export interface SupporterSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supporterImportBatch_select". + */ +export interface SupporterImportBatchSelect { + batchId?: T; + actor?: T; + expiresAt?: T; + rows?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "nucleusUpdate_select". diff --git a/src/payload.config.ts b/src/payload.config.ts index 202cdcec..0cc1f16c 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -24,6 +24,7 @@ import { Post } from './collections/Post' import { Signature } from './collections/Signature' import { Subscription } from './collections/Subscription' import { Supporter } from './collections/Supporter' +import { SupporterImportBatch } from './collections/SupporterImportBatch' import { Tag } from './collections/Tag' import { Users } from './collections/Users' import { HomePage } from './globals/HomePage' @@ -51,6 +52,7 @@ export default buildConfig({ ElectoralNucleus, Leadership, Supporter, + SupporterImportBatch, NucleusUpdate, ActionPlan, ElectionTally, diff --git a/src/utilities/actionPlanUi.ts b/src/utilities/actionPlanUi.ts index 1c66afd5..081436b6 100644 --- a/src/utilities/actionPlanUi.ts +++ b/src/utilities/actionPlanUi.ts @@ -7,6 +7,14 @@ import { territoryForCity, } from '@/lib/bahiaTerritories' import { actionPlanKinds, actionPlanStatuses } from '@/lib/schemas/actionPlan' +import { + buildListHref, + firstValue, + normalizedText, + resolveListUrl, + strictDecimalInteger, + type RawSearchParams as CampaignListRawSearchParams, +} from '@/utilities/campaignListUrl' import { normalizeSearchPhrase } from '@/utilities/wordStartFilter' export const actionPlanPageSize = 25 @@ -33,27 +41,12 @@ export type ActionPlanListState = { city?: string } -type RawSearchParams = Record +type RawSearchParams = CampaignListRawSearchParams export const actionPlanListParamNames = ['tab', 'kind', 'status', 'region', 'city', 'page'] as const const actionPlanListParamNameSet = new Set(actionPlanListParamNames) -const firstValue = (value: string | string[] | undefined): string | undefined => - Array.isArray(value) ? value[0] : value - -const normalizedText = (value: string | undefined): string | undefined => { - if (typeof value !== 'string') return undefined - const normalized = value.trim() - return normalized || undefined -} - -const strictDecimalInteger = (value: string | undefined): number | undefined => { - if (!value || !/^[1-9]\d*$/.test(value)) return undefined - const number = Number(value) - return Number.isSafeInteger(number) ? number : undefined -} - const canonicalTerritoryBySearchValue = new Map( bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]), ) @@ -155,31 +148,8 @@ export const buildActionPlanListSearchParams = ( export const buildActionPlanFiltersKey = (state: ActionPlanListState): string => buildActionPlanListSearchParams(state).toString() -export const buildActionPlanListHref = (state: ActionPlanListState, page: number): string => { - const params = buildActionPlanListSearchParams(state, page) - const query = params.toString() - return query ? `/campanha/planos?${query}` : '/campanha/planos' -} - -const inspectRawActionPlanListParams = ( - params: RawSearchParams, -): { hasUnsupportedParams: boolean; query: string } => { - const serialized = new URLSearchParams() - let hasUnsupportedParams = false - - for (const [name, value] of Object.entries(params)) { - if (!actionPlanListParamNameSet.has(name)) { - hasUnsupportedParams = true - continue - } - if (value === undefined) continue - for (const item of Array.isArray(value) ? value : [value]) { - serialized.append(name, item) - } - } - - return { hasUnsupportedParams, query: serialized.toString() } -} +export const buildActionPlanListHref = (state: ActionPlanListState, page: number): string => + buildListHref(state, buildActionPlanListSearchParams, '/campanha/planos', page) export const resolveActionPlanListUrl = ( params: RawSearchParams, @@ -188,22 +158,12 @@ export const resolveActionPlanListUrl = ( state: ActionPlanListState href: string redirectHref?: string -} => { - const parsedState = parseActionPlanListParams(params) - const page = - totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages - ? totalPages - : parsedState.page - const state = page === parsedState.page ? parsedState : { ...parsedState, page } - const canonicalParams = buildActionPlanListSearchParams(state) - const canonicalQuery = canonicalParams.toString() - const href = canonicalQuery ? `/campanha/planos?${canonicalQuery}` : '/campanha/planos' - const raw = inspectRawActionPlanListParams(params) - const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery - - return { - state, - href, - ...(needsRedirect ? { redirectHref: href } : {}), - } -} +} => + resolveListUrl({ + params, + paramNameSet: actionPlanListParamNameSet, + parse: parseActionPlanListParams, + buildSearchParams: buildActionPlanListSearchParams, + basePath: '/campanha/planos', + totalPages, + }) diff --git a/src/utilities/campaignFormActionError.ts b/src/utilities/campaignFormActionError.ts new file mode 100644 index 00000000..7d6ec28d --- /dev/null +++ b/src/utilities/campaignFormActionError.ts @@ -0,0 +1,40 @@ +import { ZodError } from 'zod' + +import { FormDataBoundaryError, validationFieldErrors } from '@/lib/formData' + +export type CampaignFormErrorState = { + fieldErrors?: Record + message?: string + values?: Values + revision?: number +} + +export type MapCampaignFormActionErrorOptions = { + error: unknown + safeMessages: readonly string[] + genericMessage: string + values?: Values + revision?: number + resolveBoundaryMessage?: (error: FormDataBoundaryError) => string +} + +export const mapCampaignFormActionError = ( + options: MapCampaignFormActionErrorOptions, +): CampaignFormErrorState => { + const { error, safeMessages, genericMessage, values, revision, resolveBoundaryMessage } = options + + if (error instanceof FormDataBoundaryError) { + const message = resolveBoundaryMessage ? resolveBoundaryMessage(error) : error.message + return { fieldErrors: { [error.field]: [message] }, values, revision } + } + if (error instanceof ZodError) { + return { fieldErrors: validationFieldErrors(error), values, revision } + } + if ( + error instanceof Error && + safeMessages.includes(error.message as (typeof safeMessages)[number]) + ) { + return { message: error.message, values, revision } + } + return { message: genericMessage, values, revision } +} diff --git a/src/utilities/campaignFormFields.ts b/src/utilities/campaignFormFields.ts new file mode 100644 index 00000000..81b291c4 --- /dev/null +++ b/src/utilities/campaignFormFields.ts @@ -0,0 +1,21 @@ +export const fieldError = ( + fieldErrors: Record | undefined, + field: string, +): string | undefined => fieldErrors?.[field]?.[0] + +export const errorProps = ( + fieldErrors: Record | undefined, + field: string, + idPrefix: string, +): { + error: string | undefined + invalid: boolean + describedBy: string | undefined +} => { + const error = fieldError(fieldErrors, field) + return { + error, + invalid: Boolean(error), + describedBy: error ? `${idPrefix}-${field}-error` : undefined, + } +} diff --git a/src/utilities/campaignListUrl.ts b/src/utilities/campaignListUrl.ts new file mode 100644 index 00000000..bfb09b9f --- /dev/null +++ b/src/utilities/campaignListUrl.ts @@ -0,0 +1,86 @@ +export type RawSearchParams = Record + +export const firstValue = (value: string | string[] | undefined): string | undefined => + Array.isArray(value) ? value[0] : value + +export const normalizedText = (value: string | undefined): string | undefined => { + if (typeof value !== 'string') return undefined + const normalized = value.trim() + return normalized || undefined +} + +export const strictDecimalInteger = (value: string | undefined): number | undefined => { + if (!value || !/^[1-9]\d*$/.test(value)) return undefined + const number = Number(value) + return Number.isSafeInteger(number) ? number : undefined +} + +export const inspectRawListParams = ( + params: RawSearchParams, + paramNameSet: ReadonlySet, +): { hasUnsupportedParams: boolean; query: string } => { + const serialized = new URLSearchParams() + let hasUnsupportedParams = false + + for (const [name, value] of Object.entries(params)) { + if (!paramNameSet.has(name)) { + hasUnsupportedParams = true + continue + } + if (value === undefined) continue + for (const item of Array.isArray(value) ? value : [value]) { + serialized.append(name, item) + } + } + + return { hasUnsupportedParams, query: serialized.toString() } +} + +export type ListStateWithPage = { page: number } + +export type ResolveListUrlOptions = { + params: RawSearchParams + paramNameSet: ReadonlySet + parse: (params: RawSearchParams) => State + buildSearchParams: (state: State) => URLSearchParams + basePath: string + totalPages?: number +} + +export const resolveListUrl = ( + options: ResolveListUrlOptions, +): { + state: State + href: string + redirectHref?: string +} => { + const { params, paramNameSet, parse, buildSearchParams, basePath, totalPages } = options + const parsedState = parse(params) + const page = + totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages + ? totalPages + : parsedState.page + const state = page === parsedState.page ? parsedState : ({ ...parsedState, page } as State) + const canonicalParams = buildSearchParams(state) + const canonicalQuery = canonicalParams.toString() + const href = canonicalQuery ? `${basePath}?${canonicalQuery}` : basePath + const raw = inspectRawListParams(params, paramNameSet) + const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery + + return { + state, + href, + ...(needsRedirect ? { redirectHref: href } : {}), + } +} + +export const buildListHref = ( + state: State, + buildSearchParams: (state: State, page?: number) => URLSearchParams, + basePath: string, + page: number, +): string => { + const params = buildSearchParams(state, page) + const query = params.toString() + return query ? `${basePath}?${query}` : basePath +} diff --git a/src/utilities/nucleusUi.ts b/src/utilities/nucleusUi.ts index cf3e376b..e75d9f6b 100644 --- a/src/utilities/nucleusUi.ts +++ b/src/utilities/nucleusUi.ts @@ -7,6 +7,14 @@ import { territoryForCity, } from '@/lib/bahiaTerritories' import type { CampaignUser, ElectoralNucleus } from '@/payload-types' +import { + buildListHref, + firstValue, + normalizedText, + resolveListUrl, + strictDecimalInteger, + type RawSearchParams as CampaignListRawSearchParams, +} from '@/utilities/campaignListUrl' import { normalizeSearchPhrase } from '@/utilities/wordStartFilter' export const nucleusPageSize = 25 @@ -63,7 +71,7 @@ export type NucleusListState = { estimate?: 'confirmada' | 'sem_confirmacao' } -type RawSearchParams = Record +type RawSearchParams = CampaignListRawSearchParams export const nucleusListParamNames = [ 'q', @@ -77,21 +85,6 @@ export const nucleusListParamNames = [ const nucleusListParamNameSet = new Set(nucleusListParamNames) -const firstValue = (value: string | string[] | undefined): string | undefined => - Array.isArray(value) ? value[0] : value - -const normalizedText = (value: FormDataEntryValue | string | undefined): string | undefined => { - if (typeof value !== 'string') return undefined - const normalized = value.trim() - return normalized || undefined -} - -const strictDecimalInteger = (value: string | undefined): number | undefined => { - if (!value || !/^[1-9]\d*$/.test(value)) return undefined - const number = Number(value) - return Number.isSafeInteger(number) ? number : undefined -} - const canonicalTerritoryBySearchValue = new Map( bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]), ) @@ -198,31 +191,8 @@ export const buildNucleusListSearchParams = ( export const buildNucleusFiltersKey = (state: NucleusListState): string => buildNucleusListSearchParams(state).toString() -export const buildNucleusListHref = (state: NucleusListState, page: number): string => { - const params = buildNucleusListSearchParams(state, page) - const query = params.toString() - return query ? `/campanha/nucleos?${query}` : '/campanha/nucleos' -} - -const inspectRawNucleusListParams = ( - params: RawSearchParams, -): { hasUnsupportedParams: boolean; query: string } => { - const serialized = new URLSearchParams() - let hasUnsupportedParams = false - - for (const [name, value] of Object.entries(params)) { - if (!nucleusListParamNameSet.has(name)) { - hasUnsupportedParams = true - continue - } - if (value === undefined) continue - for (const item of Array.isArray(value) ? value : [value]) { - serialized.append(name, item) - } - } - - return { hasUnsupportedParams, query: serialized.toString() } -} +export const buildNucleusListHref = (state: NucleusListState, page: number): string => + buildListHref(state, buildNucleusListSearchParams, '/campanha/nucleos', page) export const resolveNucleusListUrl = ( params: RawSearchParams, @@ -231,25 +201,15 @@ export const resolveNucleusListUrl = ( state: NucleusListState href: string redirectHref?: string -} => { - const parsedState = parseNucleusListParams(params) - const page = - totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages - ? totalPages - : parsedState.page - const state = page === parsedState.page ? parsedState : { ...parsedState, page } - const canonicalParams = buildNucleusListSearchParams(state) - const canonicalQuery = canonicalParams.toString() - const href = canonicalQuery ? `/campanha/nucleos?${canonicalQuery}` : '/campanha/nucleos' - const raw = inspectRawNucleusListParams(params) - const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery - - return { - state, - href, - ...(needsRedirect ? { redirectHref: href } : {}), - } -} +} => + resolveListUrl({ + params, + paramNameSet: nucleusListParamNameSet, + parse: parseNucleusListParams, + buildSearchParams: buildNucleusListSearchParams, + basePath: '/campanha/nucleos', + totalPages, + }) export const getCampaignScopeLabel = (role: CampaignUser['role'], nucleusCount: number): string => { if (role === 'coordenador') { diff --git a/src/utilities/supporterImport.ts b/src/utilities/supporterImport.ts index 3fcba4f4..df25283e 100644 --- a/src/utilities/supporterImport.ts +++ b/src/utilities/supporterImport.ts @@ -33,12 +33,17 @@ export type SupporterImportPreviewRow = }) export type SupporterImportPreviewResult = { - rows: SupporterImportPreviewRow[] counts: { ok: number duplicate: number error: number } + /** First N rows for the on-screen preview table (the full set stays server-side). */ + sampleRows: SupporterImportPreviewRow[] + /** Precomputed CSV of every error row, for the "Baixar relatório de erros" button. */ + errorReportCsv: string + /** HMAC-signed token that `confirmSupporterImport` exchanges for the staged batch. */ + importToken: string } export const isSupporterImportOkRow = ( diff --git a/src/utilities/supporterImportBulk.ts b/src/utilities/supporterImportBulk.ts new file mode 100644 index 00000000..75b37079 --- /dev/null +++ b/src/utilities/supporterImportBulk.ts @@ -0,0 +1,256 @@ +import 'server-only' + +import type { Payload } from 'payload' + +import type { SupporterVoteIntention } from '@/lib/schemas/supporter' +import type { ConsentDescriptor } from '@/utilities/campaignConsent' +import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' +import { getPostgresTransactionDatabase } from '@/utilities/postgresTransactionLocks' + +export type SupporterImportBulkRow = { + telefone: string + nome: string + municipio?: string + intencao?: SupporterVoteIntention +} + +export type SupporterImportBulkResult = { + created: number + skipped: number + errors: Array<{ telefone: string; message: string }> +} + +type DrizzleInsertResult = { rowCount?: number | null } + +type DrizzleTx = { + insert: (table: unknown) => { + values: (rows: Record[]) => { + onConflictDoNothing: () => Promise + } + } +} + +type DrizzleTables = Record> + +type PayloadDb = { + drizzle: unknown + tables: DrizzleTables +} + +const CONTACT_TABLE = 'contact' +const SUPPORTER_TABLE = 'supporter' + +/** ~16 columns × 500 ≈ 8000 bind params — well under PG 65535. */ +const INSERT_CHUNK_SIZE = 500 + +const chunk = (rows: readonly T[], size: number): T[][] => { + const out: T[][] = [] + for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size) as T[]) + return out +} + +const getTables = (payload: Payload): DrizzleTables => + (payload.db as unknown as PayloadDb).tables + +const requireTable = (tables: DrizzleTables, name: string) => { + const table = tables[name] + if (!table) throw new Error(`Tabela drizzle ausente: ${name}. Rode as migrations.`) + return table +} + +const buildContactRows = ( + rows: SupporterImportBulkRow[], + now: string, +): Array> => + rows.map((row) => ({ + name: row.nome, + phone: row.telefone, + email: null, + state: 'BA', + city: row.municipio ?? null, + postalCode: null, + gender: null, + createdAt: now, + updatedAt: now, + })) + +const buildSupporterRows = ( + entries: Array<{ + row: SupporterImportBulkRow + contactID: number + }>, + args: { + actorID: number + registrationConsent: ConsentDescriptor + voteIntentionConsent: ConsentDescriptor + consentNote: string + now: string + }, +): Array> => + entries.map(({ row, contactID }) => ({ + // Drizzle column names for Payload relationship fields omit the `Id` suffix. + contact: contactID, + nucleus: null, + voteIntention: row.intencao ?? null, + consent: args.registrationConsent.id, + consentContentHash: args.registrationConsent.contentHash, + consentedAt: args.now, + voteIntentionConsent: row.intencao ? args.voteIntentionConsent.id : null, + voteIntentionConsentContentHash: row.intencao + ? args.voteIntentionConsent.contentHash + : null, + voteIntentionConsentedAt: row.intencao ? args.now : null, + source: 'import_csv', + consentNote: args.consentNote, + notes: null, + createdBy: args.actorID, + createdAt: args.now, + updatedAt: args.now, + })) + +/** + * Bulk-insert contacts and nucleus-less supporters for a CSV import inside the + * caller's Payload transaction. Phone advisory locks MUST already be held for + * every phone in `rows` (the caller acquires them before invoking this helper). + * + * Contacts are inserted via drizzle (bypassing the Contact phone-invariant hook, + * which is safe because the locks guarantee uniqueness within the txn). The + * supporter insert uses `ON CONFLICT DO NOTHING` on the unique + * `(contact_id, nucleus_id)` index as a last-resort guard against races. + */ +export const bulkInsertSupporterImport = async (args: { + payload: Payload + req: PayloadTransactionRequest + actorID: number + rows: SupporterImportBulkRow[] + registrationConsent: ConsentDescriptor + voteIntentionConsent: ConsentDescriptor + consentNote: string +}): Promise => { + const { payload, req, actorID, rows } = args + if (rows.length === 0) { + return { created: 0, skipped: 0, errors: [] } + } + + const tables = getTables(payload) + const contactTable = requireTable(tables, CONTACT_TABLE) + const supporterTable = requireTable(tables, SUPPORTER_TABLE) + const database = await getPostgresTransactionDatabase(payload, req) + const tx = database as unknown as DrizzleTx + const now = new Date().toISOString() + + const phones = [...new Set(rows.map((row) => row.telefone))] + + const existingContacts = await payload.find({ + collection: 'contact', + where: { phone: { in: phones } }, + depth: 0, + limit: phones.length, + pagination: false, + select: { phone: true }, + overrideAccess: true, + req, + }) + const contactIdByPhone = new Map( + existingContacts.docs.map((doc) => [doc.phone, doc.id] as const), + ) + + // New contacts to create, deduped by phone (preview already dedupes, but this + // is defensive against a stale confirm payload). + const newContactByPhone = new Map() + for (const row of rows) { + if (contactIdByPhone.has(row.telefone)) continue + if (newContactByPhone.has(row.telefone)) continue + newContactByPhone.set(row.telefone, row) + } + + if (newContactByPhone.size > 0) { + const newRows = [...newContactByPhone.values()] + for (const batch of chunk(newRows, INSERT_CHUNK_SIZE)) { + await tx.insert(contactTable).values(buildContactRows(batch, now)) + } + + const newPhones = [...newContactByPhone.keys()] + const createdContacts = await payload.find({ + collection: 'contact', + where: { phone: { in: newPhones } }, + depth: 0, + limit: newPhones.length, + pagination: false, + select: { phone: true }, + overrideAccess: true, + req, + }) + for (const doc of createdContacts.docs) { + contactIdByPhone.set(doc.phone, doc.id) + } + } + + const contactIDs = [...new Set(contactIdByPhone.values())] + const existingSupporters = + contactIDs.length > 0 + ? await payload.find({ + collection: 'supporter', + where: { + and: [{ contact: { in: contactIDs } }, { nucleus: { exists: false } }], + }, + depth: 0, + limit: contactIDs.length, + pagination: false, + select: { contact: true }, + overrideAccess: true, + req, + }) + : null + const supporterContactIDs = new Set( + (existingSupporters?.docs ?? []) + .map((doc) => + typeof doc.contact === 'number' + ? doc.contact + : typeof doc.contact === 'object' && doc.contact !== null + ? doc.contact.id + : null, + ) + .filter((id): id is number => id !== null), + ) + + const candidateEntries: Array<{ row: SupporterImportBulkRow; contactID: number }> = [] + const errors: Array<{ telefone: string; message: string }> = [] + let skipped = 0 + + for (const row of rows) { + const contactID = contactIdByPhone.get(row.telefone) + if (!contactID) { + skipped += 1 + errors.push({ telefone: row.telefone, message: 'Contato não encontrado após upsert.' }) + continue + } + if (supporterContactIDs.has(contactID)) { + skipped += 1 + continue + } + candidateEntries.push({ row, contactID }) + // Reserve against duplicate phones within the same payload. + supporterContactIDs.add(contactID) + } + + let created = 0 + for (const batch of chunk(candidateEntries, INSERT_CHUNK_SIZE)) { + if (batch.length === 0) continue + const result = await tx + .insert(supporterTable) + .values( + buildSupporterRows(batch, { + actorID, + registrationConsent: args.registrationConsent, + voteIntentionConsent: args.voteIntentionConsent, + consentNote: args.consentNote, + now, + }), + ) + .onConflictDoNothing() + created += Number(result.rowCount ?? batch.length) + } + + return { created, skipped, errors } +} diff --git a/src/utilities/supporterImportToken.ts b/src/utilities/supporterImportToken.ts new file mode 100644 index 00000000..fbb8a5c8 --- /dev/null +++ b/src/utilities/supporterImportToken.ts @@ -0,0 +1,165 @@ +import 'server-only' + +import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto' + +import type { Payload } from 'payload' + +import type { SupporterVoteIntention } from '@/lib/schemas/supporter' +import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' + +export type SupporterImportOkRow = { + nome: string + telefone: string + municipio?: string + intencao?: SupporterVoteIntention +} + +export type SupporterImportBatch = { + actorID: number + expiresAt: number + okRows: SupporterImportOkRow[] +} + +const BATCH_COLLECTION = 'supporterImportBatch' +/** Short-lived server-side staging; the wizard must confirm within this window. */ +const IMPORT_TOKEN_TTL_MS = 10 * 60 * 1000 + +export const storeSupporterImportBatch = async ( + payload: Payload, + batchId: string, + batch: SupporterImportBatch, + req?: PayloadTransactionRequest, +): Promise => { + await payload.create({ + collection: BATCH_COLLECTION, + data: { + batchId, + actor: batch.actorID, + expiresAt: new Date(batch.expiresAt).toISOString(), + rows: batch.okRows, + }, + depth: 0, + overrideAccess: true, + req, + }) +} + +export const loadSupporterImportBatch = async ( + payload: Payload, + batchId: string, + expectedActorID: number, + req?: PayloadTransactionRequest, +): Promise => { + const result = await payload.find({ + collection: BATCH_COLLECTION, + where: { batchId: { equals: batchId } }, + depth: 0, + limit: 1, + pagination: false, + overrideAccess: true, + req, + }) + const doc = result.docs[0] as + | { id: number; actor: unknown; expiresAt?: string; rows?: unknown } + | undefined + if (!doc) throw new Error('Lote de importação não encontrado ou expirado.') + + const actorID = + typeof doc.actor === 'number' + ? doc.actor + : typeof doc.actor === 'object' && doc.actor !== null && 'id' in doc.actor + ? (doc.actor as { id: number }).id + : null + if (actorID !== expectedActorID) { + throw new Error('Lote de importação inválido.') + } + + const expiresAtMs = doc.expiresAt ? Date.parse(doc.expiresAt) : NaN + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) { + throw new Error('Lote de importação expirado.') + } + if (!Array.isArray(doc.rows)) { + throw new Error('Lote de importação inválido.') + } + + return { + actorID: expectedActorID, + expiresAt: expiresAtMs, + okRows: doc.rows as SupporterImportOkRow[], + } +} + +export const deleteSupporterImportBatch = async ( + payload: Payload, + batchId: string, + req?: PayloadTransactionRequest, +): Promise => { + const result = await payload.find({ + collection: BATCH_COLLECTION, + where: { batchId: { equals: batchId } }, + depth: 0, + limit: 1, + pagination: false, + overrideAccess: true, + req, + }) + const doc = result.docs[0] as { id: number } | undefined + if (!doc) return + await payload.delete({ + collection: BATCH_COLLECTION, + id: doc.id, + depth: 0, + overrideAccess: true, + req, + }) +} + +const base64url = (buffer: Buffer): string => + buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + +const base64urlToBuffer = (value: string): Buffer => { + const padded = value.replace(/-/g, '+').replace(/_/g, '/') + return Buffer.from(padded, 'base64') +} + +const getSecret = (): string => { + const secret = process.env.PAYLOAD_SECRET + if (!secret) throw new Error('PAYLOAD_SECRET não configurado para o token de importação.') + return secret +} + +const signature = (batchId: string, actorID: number, expiresAt: number): Buffer => + createHmac('sha256', getSecret()).update(`${batchId}.${actorID}.${expiresAt}`).digest() + +export type SignedImportToken = { + token: string + batchId: string + expiresAt: number +} + +export const issueSupporterImportToken = (actorID: number): SignedImportToken => { + const batchId = randomUUID() + const expiresAt = Date.now() + IMPORT_TOKEN_TTL_MS + const sig = base64url(signature(batchId, actorID, expiresAt)) + return { token: `${batchId}.${expiresAt}.${sig}`, batchId, expiresAt } +} + +export const verifySupporterImportToken = ( + token: string, + actorID: number, +): { batchId: string; expiresAt: number } => { + const parts = token.split('.') + if (parts.length !== 3) throw new Error('Token de importação inválido.') + const [batchId, expiresAtText, sig] = parts as [string, string, string] + const expiresAt = Number(expiresAtText) + if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) { + throw new Error('Token de importação expirado.') + } + const expected = signature(batchId, actorID, expiresAt) + const provided = base64urlToBuffer(sig) + if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) { + throw new Error('Token de importação inválido.') + } + return { batchId, expiresAt } +} + diff --git a/src/utilities/supporterListOverviewAggregate.ts b/src/utilities/supporterListOverviewAggregate.ts new file mode 100644 index 00000000..6550546c --- /dev/null +++ b/src/utilities/supporterListOverviewAggregate.ts @@ -0,0 +1,149 @@ +import 'server-only' + +import { sql } from '@payloadcms/db-postgres' +import type { Payload } from 'payload' + +import type { CampaignUser } from '@/payload-types' +import { isCampaignGeneral } from '@/utilities/campaignAccess' +import { normalizeBrazilianPhone } from '@/utilities/phone' +import type { SupporterListState } from '@/utilities/supporterUi' +import type { SupporterListOverviewViewModel } from '@/utilities/supporterViewModels' + +type AccessConstraint = + | { kind: 'all' } + | { kind: 'none' } + | { kind: 'nucleusSet'; ids: number[] } + +type PostgresDb = { + execute: (query: ReturnType) => Promise +} + +const resultRows = (result: unknown): Array> => { + if (Array.isArray(result)) return result as Array> + if ( + typeof result === 'object' && + result !== null && + 'rows' in result && + Array.isArray((result as { rows: unknown }).rows) + ) { + return (result as { rows: Array> }).rows + } + return [] +} + +const resolveAccessConstraint = async ( + payload: Pick, + user: CampaignUser, +): Promise => { + if (isCampaignGeneral(user)) return { kind: 'all' } + if (user.role !== 'coordenador') return { kind: 'none' } + + const result = await payload.find({ + collection: 'electoralNucleus', + where: { coordinators: { contains: user.id } }, + depth: 0, + pagination: false, + overrideAccess: true, + }) + + const ids = result.docs + .map((doc) => (typeof doc.id === 'number' ? doc.id : Number(doc.id))) + .filter((id) => Number.isSafeInteger(id)) + + return { kind: 'nucleusSet', ids } +} + +const buildAggregateSql = ( + state: SupporterListState, + access: AccessConstraint, +): ReturnType => { + const conditions: ReturnType[] = [] + const needsContactJoin = Boolean(state.q || state.city) + + if (access.kind === 'nucleusSet') { + if (access.ids.length === 0) { + // No accessible nuclei → no rows. Keep a trivially-false condition. + conditions.push(sql`FALSE`) + } else { + conditions.push( + sql`"supporter"."nucleus_id" IN (${sql.join( + access.ids.map((id) => sql`${id}`), + sql`, `, + )})`, + ) + } + } + + if (state.voteIntention) { + conditions.push(sql`"supporter"."vote_intention" = ${state.voteIntention}`) + } + + if (state.nucleus) { + conditions.push(sql`"supporter"."nucleus_id" = ${state.nucleus}`) + } + + if (state.city) { + conditions.push(sql`"contact"."city" = ${state.city}`) + } + + if (state.q) { + const searchTerms: ReturnType[] = [ + sql`"contact"."name" ILIKE ${`%${state.q}%`}`, + sql`"contact"."city" ILIKE ${`%${state.q}%`}`, + ] + const normalizedPhone = normalizeBrazilianPhone(state.q) + if (normalizedPhone) { + searchTerms.push(sql`"contact"."phone" = ${normalizedPhone}`) + } else if (/\d/.test(state.q)) { + const digits = state.q.replace(/\D/g, '') + if (digits) searchTerms.push(sql`"contact"."phone" ILIKE ${`%${digits}%`}`) + } + conditions.push(sql`(${sql.join(searchTerms, sql` OR `)})`) + } + + const whereClause = + conditions.length > 0 ? sql` WHERE ${sql.join(conditions, sql` AND `)}` : sql`` + + const fromClause = needsContactJoin + ? sql`FROM "supporter" LEFT JOIN "contact" ON "contact"."id" = "supporter"."contact_id"` + : sql`FROM "supporter"` + + return sql` + SELECT + COUNT(*) AS "total", + COUNT(*) FILTER (WHERE "supporter"."vote_intention" IN ('certo', 'tende_a_certo')) AS "certo_and_tende", + COUNT(*) FILTER (WHERE "supporter"."vote_intention" = 'indeciso') AS "indeciso" + ${fromClause}${whereClause} + ` +} + +export const computeSupporterListOverviewAggregate = async ( + payload: Payload, + user: CampaignUser, + state: SupporterListState, +): Promise => { + const access = await resolveAccessConstraint(payload, user) + if (access.kind === 'none') return null + + if (payload.db.name !== 'postgres') { + throw new Error('O overview de apoiadores exige o adaptador PostgreSQL.') + } + const database = payload.db as unknown as { drizzle?: PostgresDb } + const drizzle = database.drizzle + if (!drizzle || typeof drizzle.execute !== 'function') { + throw new Error('A sessão PostgreSQL do overview de apoiadores não está disponível.') + } + + const result = await drizzle.execute(buildAggregateSql(state, access)) + const row = resultRows(result)[0] + if (!row) return null + + const total = Number(row.total) + if (!Number.isSafeInteger(total) || total === 0) return null + + return { + total, + certoAndTende: Number(row.certo_and_tende ?? 0), + indeciso: Number(row.indeciso ?? 0), + } +} diff --git a/src/utilities/supporterPageData.ts b/src/utilities/supporterPageData.ts index ab6d7150..133a9025 100644 --- a/src/utilities/supporterPageData.ts +++ b/src/utilities/supporterPageData.ts @@ -5,6 +5,7 @@ import { getSupporterRegistrationConsent, getSupporterVoteIntentionConsent, } from '@/utilities/campaignConsent' +import { computeSupporterListOverviewAggregate } from '@/utilities/supporterListOverviewAggregate' import { buildSupporterListWhere, resolveSupporterListUrl, @@ -102,45 +103,11 @@ export const loadSupporterListPageData = async ( } export const loadSupporterListOverviewData = async ( - payload: Pick, + payload: Payload, user: CampaignUser, state: SupporterListState, -): Promise => { - const where = buildSupporterListWhere(state) - - const [total, certoAndTende, indeciso] = await Promise.all([ - payload.count({ - collection: 'supporter', - where, - user, - overrideAccess: false, - }), - payload.count({ - collection: 'supporter', - where: { - and: [where, { voteIntention: { in: ['certo', 'tende_a_certo'] } }], - }, - user, - overrideAccess: false, - }), - payload.count({ - collection: 'supporter', - where: { - and: [where, { voteIntention: { equals: 'indeciso' } }], - }, - user, - overrideAccess: false, - }), - ]) - - if (total.totalDocs === 0) return null - - return { - total: total.totalDocs, - certoAndTende: certoAndTende.totalDocs, - indeciso: indeciso.totalDocs, - } -} +): Promise => + computeSupporterListOverviewAggregate(payload, user, state) export const loadSupporterDetailPageData = async ( payload: Pick, diff --git a/src/utilities/supporterUi.ts b/src/utilities/supporterUi.ts index ace87b5a..18c6c32d 100644 --- a/src/utilities/supporterUi.ts +++ b/src/utilities/supporterUi.ts @@ -6,6 +6,14 @@ import { type SupporterVoteIntention, } from '@/lib/schemas/supporter' import type { CampaignUser, Supporter } from '@/payload-types' +import { + buildListHref, + firstValue, + normalizedText, + resolveListUrl, + strictDecimalInteger, + type RawSearchParams as CampaignListRawSearchParams, +} from '@/utilities/campaignListUrl' import { normalizeBrazilianPhone } from '@/utilities/phone' export const supporterPageSize = 25 @@ -32,27 +40,12 @@ export type SupporterListState = { nucleus?: number } -type RawSearchParams = Record +type RawSearchParams = CampaignListRawSearchParams export const supporterListParamNames = ['q', 'voteIntention', 'city', 'nucleus', 'page'] as const const supporterListParamNameSet = new Set(supporterListParamNames) -const firstValue = (value: string | string[] | undefined): string | undefined => - Array.isArray(value) ? value[0] : value - -const normalizedText = (value: FormDataEntryValue | string | undefined): string | undefined => { - if (typeof value !== 'string') return undefined - const normalized = value.trim() - return normalized || undefined -} - -const strictDecimalInteger = (value: string | undefined): number | undefined => { - if (!value || !/^[1-9]\d*$/.test(value)) return undefined - const number = Number(value) - return Number.isSafeInteger(number) ? number : undefined -} - export const parseSupporterListParams = (params: RawSearchParams): SupporterListState => { const rawPage = strictDecimalInteger(firstValue(params.page)) const q = normalizedText(firstValue(params.q)) @@ -125,31 +118,8 @@ export const buildSupporterListSearchParams = ( export const buildSupporterFiltersKey = (state: SupporterListState): string => buildSupporterListSearchParams(state).toString() -export const buildSupporterListHref = (state: SupporterListState, page: number): string => { - const params = buildSupporterListSearchParams(state, page) - const query = params.toString() - return query ? `/campanha/apoiadores?${query}` : '/campanha/apoiadores' -} - -const inspectRawSupporterListParams = ( - params: RawSearchParams, -): { hasUnsupportedParams: boolean; query: string } => { - const serialized = new URLSearchParams() - let hasUnsupportedParams = false - - for (const [name, value] of Object.entries(params)) { - if (!supporterListParamNameSet.has(name)) { - hasUnsupportedParams = true - continue - } - if (value === undefined) continue - for (const item of Array.isArray(value) ? value : [value]) { - serialized.append(name, item) - } - } - - return { hasUnsupportedParams, query: serialized.toString() } -} +export const buildSupporterListHref = (state: SupporterListState, page: number): string => + buildListHref(state, buildSupporterListSearchParams, '/campanha/apoiadores', page) export const resolveSupporterListUrl = ( params: RawSearchParams, @@ -158,25 +128,15 @@ export const resolveSupporterListUrl = ( state: SupporterListState href: string redirectHref?: string -} => { - const parsedState = parseSupporterListParams(params) - const page = - totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages - ? totalPages - : parsedState.page - const state = page === parsedState.page ? parsedState : { ...parsedState, page } - const canonicalParams = buildSupporterListSearchParams(state) - const canonicalQuery = canonicalParams.toString() - const href = canonicalQuery ? `/campanha/apoiadores?${canonicalQuery}` : '/campanha/apoiadores' - const raw = inspectRawSupporterListParams(params) - const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery - - return { - state, - href, - ...(needsRedirect ? { redirectHref: href } : {}), - } -} +} => + resolveListUrl({ + params, + paramNameSet: supporterListParamNameSet, + parse: parseSupporterListParams, + buildSearchParams: buildSupporterListSearchParams, + basePath: '/campanha/apoiadores', + totalPages, + }) export const getSupporterScopeLabel = (total: number): string => `${total} ${total === 1 ? 'apoiador nos seus núcleos' : 'apoiadores nos seus núcleos'}` diff --git a/tests/e2e/campaign-supporters.e2e.spec.ts b/tests/e2e/campaign-supporters.e2e.spec.ts new file mode 100644 index 00000000..03758559 --- /dev/null +++ b/tests/e2e/campaign-supporters.e2e.spec.ts @@ -0,0 +1,77 @@ +/* eslint-disable check-file/filename-naming-convention */ +import { test, expect } from './fixtures/campaignE2EFixtures' + +test.describe('Campaign supporter journeys', () => { + test.describe.configure({ mode: 'parallel' }) + + test('removes a supporter through the confirmation dialog', async ({ campaign, page }) => { + test.setTimeout(240_000) + const generalEmail = `${campaign.fixtures.value('removal-general')}@example.com` + const password = campaign.fixtures.value('RemovalPassword') + const supporterName = campaign.fixtures.value('Apoiador removível') + + const setup = await campaign.transaction(async (payload, req) => { + const general = await payload.create({ + collection: 'campaignUser', + data: { + name: campaign.fixtures.value('Coordenação Geral remoção'), + email: generalEmail, + password, + role: 'geral', + }, + depth: 0, + req, + }) + const contact = await payload.create({ + collection: 'contact', + data: { + name: supporterName, + phone: campaign.fixtures.phone(), + state: 'BA', + city: 'Salvador', + }, + depth: 0, + req, + }) + const supporter = await payload.create({ + collection: 'supporter', + data: { + contact: contact.id, + source: 'manual', + createdBy: general.id, + }, + depth: 0, + req, + }) + return { general, supporter } + }) + + await campaign.login(page, generalEmail, password) + await page.goto(`${campaign.baseURL}/campanha/apoiadores/${setup.supporter.id}`) + await page.waitForLoadState('networkidle') + + await page.getByRole('button', { name: 'Remover dados' }).click() + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 20_000 }) + await expect( + page.getByRole('heading', { name: 'Remover os dados deste apoiador?' }), + ).toBeVisible() + + await page.getByRole('button', { name: 'Confirmar remoção' }).click() + await page.waitForURL(`${campaign.baseURL}/campanha/apoiadores`, { timeout: 30_000 }) + + await expect + .poll( + async () => + ( + await campaign.payload.find({ + collection: 'supporter', + where: { id: { equals: setup.supporter.id } }, + depth: 0, + limit: 1, + }) + ).totalDocs, + { timeout: 30_000 }, + ) + .toBe(0) + }) +}) diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index 1c6bb6fa..efc59827 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -21,11 +21,13 @@ type OwnedCollection = | 'nucleusUpdate' | 'campaignInvite' | 'consent' + | 'supporter' const deletionOrder: OwnedCollection[] = [ 'campaignInvite', 'nucleusUpdate', 'leadership', + 'supporter', 'electoralNucleus', 'contact', 'campaignUser', @@ -174,6 +176,21 @@ class CampaignE2EOwnership { }) for (const update of updates.docs) this.own('nucleusUpdate', update.id) } + if (contactIDs.length || nucleusIDs.length || userIDs.length) { + const supporters = await this.rootPayload.find({ + collection: 'supporter', + where: { + or: [ + ...(contactIDs.length ? [{ contact: { in: contactIDs } }] : []), + ...(nucleusIDs.length ? [{ nucleus: { in: nucleusIDs } }] : []), + ...(userIDs.length ? [{ createdBy: { in: userIDs } }] : []), + ], + }, + depth: 0, + pagination: false, + }) + for (const supporter of supporters.docs) this.own('supporter', supporter.id) + } } async cleanup(): Promise { diff --git a/tests/int/campaignSupporter.int.spec.ts b/tests/int/campaignSupporter.int.spec.ts index 4c45d5f0..da6e25c3 100644 --- a/tests/int/campaignSupporter.int.spec.ts +++ b/tests/int/campaignSupporter.int.spec.ts @@ -16,6 +16,7 @@ import { SUPPORTER_REGISTRATION_CONSENT_KEY, SUPPORTER_VOTE_INTENTION_CONSENT_KEY, } from '@/utilities/campaignConsent' +import { loadSupporterListOverviewData } from '@/utilities/supporterPageData' import { installCampaignFixtures } from '../helpers/campaignFixtures' @@ -251,22 +252,23 @@ describe('campaign supporter domain', () => { expect(preview.counts.ok).toBe(1) expect(preview.counts.duplicate).toBe(1) expect(preview.counts.error).toBeGreaterThanOrEqual(2) - - const okRows = preview.rows - .filter((row) => row.status === 'ok') - .map((row) => ({ - nome: row.nome, - telefone: row.normalizedPhone!, - municipio: row.canonicalCity, - intencao: row.voteIntention, - })) + expect(preview.importToken).toBeTruthy() + expect(preview.sampleRows.length).toBeGreaterThan(0) const result = await confirmSupporterImportRecord(payload, geral, { operatorAttested: true, - rows: okRows, + importToken: preview.importToken, }) expect(result.created).toBe(1) + // Single-use token: a second confirm with the same token must fail. + await expect( + confirmSupporterImportRecord(payload, geral, { + operatorAttested: true, + importToken: preview.importToken, + }), + ).rejects.toThrow(/não encontrado|expirado|inválido/) + const contact = await payload.find({ collection: 'contact', where: { phone: { equals: phoneOk } }, @@ -288,6 +290,57 @@ describe('campaign supporter domain', () => { fixtures.own('supporter', created.docs[0]!.id) }) + it('rejects a tampered import token and a token issued to another actor', async () => { + const fixtures = campaignFixtures() + await ensureSupporterConsents(fixtures) + const geral = await fixtures.createCampaignUser('geral') + const otherGeral = await fixtures.createCampaignUser('geral') + const phoneOk = fixtures.phone() + const csv = `nome,telefone,municipio,intencao\nAna Silva,${phoneOk},Salvador,certo` + + const preview = await previewSupporterImportText(payload, geral, csv) + expect(preview.counts.ok).toBe(1) + + const [batchId, expiresAt, sig] = preview.importToken.split('.') + const tampered = `${batchId}.${expiresAt}.${sig.slice(0, -2)}xx` + await expect( + confirmSupporterImportRecord(payload, geral, { + operatorAttested: true, + importToken: tampered, + }), + ).rejects.toThrow(/inválido|expirado/) + + // Token bound to a different actor cannot be redeemed by `geral`. + await expect( + confirmSupporterImportRecord(payload, otherGeral, { + operatorAttested: true, + importToken: preview.importToken, + }), + ).rejects.toThrow(/não encontrado|inválido|expirado/) + + // Cleanup: consume the still-valid token so it does not leak into other tests. + await confirmSupporterImportRecord(payload, geral, { + operatorAttested: true, + importToken: preview.importToken, + }) + const contact = await payload.find({ + collection: 'contact', + where: { phone: { equals: phoneOk } }, + depth: 0, + limit: 1, + overrideAccess: true, + }) + fixtures.own('contact', contact.docs[0]!.id) + const created = await payload.find({ + collection: 'supporter', + where: { contact: { equals: contact.docs[0]!.id } }, + depth: 0, + limit: 1, + overrideAccess: true, + }) + if (created.docs[0]) fixtures.own('supporter', created.docs[0].id) + }) + it('removes supporter and anonymizes contact when no other joins remain', async () => { const fixtures = campaignFixtures() await ensureSupporterConsents(fixtures) @@ -315,4 +368,81 @@ describe('campaign supporter domain', () => { expect(contact.name).toBe('Titular removido') expect(contact.phone).not.toBe(phone) }) + + it('aggregates supporter overview KPIs in a single SQL query for geral', async () => { + const fixtures = campaignFixtures() + const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY) + const geral = await fixtures.createCampaignUser('geral') + + const intentions: Array<{ voteIntention: 'certo' | 'tende_a_certo' | 'indeciso' | 'outro' | null }> = [ + { voteIntention: 'certo' }, + { voteIntention: 'certo' }, + { voteIntention: 'tende_a_certo' }, + { voteIntention: 'indeciso' }, + { voteIntention: 'indeciso' }, + { voteIntention: 'outro' }, + { voteIntention: null }, + ] + for (const { voteIntention } of intentions) { + const contact = await fixtures.createContact() + await fixtures.createSupporter({ + contact: contact.id, + consent: registration.id, + consentContentHash: 'hash', + consentedAt: new Date().toISOString(), + createdBy: geral.id, + ...(voteIntention ? { voteIntention } : {}), + }) + } + + const overview = await loadSupporterListOverviewData(payload, geral, { page: 1 }) + expect(overview).not.toBeNull() + expect(overview!.total).toBe(intentions.length) + expect(overview!.certoAndTende).toBe(3) + expect(overview!.indeciso).toBe(2) + }) + + it('scopes the overview aggregate to accessible nuclei for coordenador and applies filters', async () => { + const fixtures = campaignFixtures() + const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY) + const coordenador = await fixtures.createCampaignUser('coordenador') + const assigned = await fixtures.createNucleus({ coordinators: [coordenador.id] }) + const other = await fixtures.createNucleus() + + const makeSupporter = async ( + nucleusId: number | undefined, + voteIntention: 'certo' | 'indeciso' | null, + city = 'Salvador', + name = fixtures.value('Apoiador'), + ) => { + const contact = await fixtures.createContact({ city, name }) + await fixtures.createSupporter({ + contact: contact.id, + consent: registration.id, + consentContentHash: 'hash', + consentedAt: new Date().toISOString(), + createdBy: coordenador.id, + ...(nucleusId ? { nucleus: nucleusId } : {}), + ...(voteIntention ? { voteIntention } : {}), + }) + } + + await makeSupporter(assigned.id, 'certo') + await makeSupporter(assigned.id, 'indeciso') + await makeSupporter(assigned.id, null, 'Feira de Santana') + await makeSupporter(other.id, 'certo') // outside scope — must not be counted + + const overview = await loadSupporterListOverviewData(payload, coordenador, { page: 1 }) + expect(overview).not.toBeNull() + expect(overview!.total).toBe(3) + expect(overview!.certoAndTende).toBe(1) + expect(overview!.indeciso).toBe(1) + + const filtered = await loadSupporterListOverviewData(payload, coordenador, { + page: 1, + city: 'Feira de Santana', + }) + expect(filtered).not.toBeNull() + expect(filtered!.total).toBe(1) + }) }) diff --git a/tests/unit/campaignNucleusUi.unit.spec.ts b/tests/unit/campaignNucleusUi.unit.spec.ts index 1326a09e..aa91f3e2 100644 --- a/tests/unit/campaignNucleusUi.unit.spec.ts +++ b/tests/unit/campaignNucleusUi.unit.spec.ts @@ -5,9 +5,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { archiveNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions' import { ArchiveNucleusDialog } from '@/components/campaign/ArchiveNucleusDialog' +import { getPaginationPages } from '@/components/campaign/CampaignListPagination' import { NucleusFormFields } from '@/components/campaign/NucleusForm' import { NucleusList } from '@/components/campaign/NucleusList' -import { getNucleusPaginationPages } from '@/components/campaign/NucleusPagination' import { NucleusTerritoryAndZonesFields } from '@/components/campaign/NucleusTerritoryAndZonesFields' import { TseZoneInput } from '@/components/campaign/TseZoneInput' import { campaignNav, getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav' @@ -498,9 +498,9 @@ describe('campaign nucleus UI contracts', () => { }) it('keeps pagination boundaries compact and valid', () => { - expect(getNucleusPaginationPages(1, 10)).toEqual([1, 2, 10]) - expect(getNucleusPaginationPages(10, 10)).toEqual([1, 9, 10]) - expect(getNucleusPaginationPages(5, 10)).toEqual([1, 4, 5, 6, 10]) + expect(getPaginationPages(1, 10)).toEqual([1, 2, 10]) + expect(getPaginationPages(10, 10)).toEqual([1, 9, 10]) + expect(getPaginationPages(5, 10)).toEqual([1, 4, 5, 6, 10]) }) it('returns a recoverable archive error for an invalid identifier', async () => { diff --git a/tests/unit/supporterSchema.unit.spec.ts b/tests/unit/supporterSchema.unit.spec.ts index 3ff67201..61daed57 100644 --- a/tests/unit/supporterSchema.unit.spec.ts +++ b/tests/unit/supporterSchema.unit.spec.ts @@ -45,19 +45,25 @@ describe('supporter schemas', () => { ).toBe(true) }) - it('requires operator attestation for import confirm', () => { + it('requires operator attestation and a token for import confirm', () => { expect( supporterImportConfirmSchema.safeParse({ - rows: [{ nome: 'Ana Silva', telefone: '71988887777' }], + importToken: 'token', + }).success, + ).toBe(false) + + expect( + supporterImportConfirmSchema.safeParse({ + operatorAttested: true, }).success, ).toBe(false) expect( supporterImportConfirmSchema.parse({ operatorAttested: true, - rows: [{ nome: 'Ana Silva', telefone: '71988887777', intencao: 'certo' }], - }).rows[0]?.telefone, - ).toBe('71988887777') + importToken: 'batch.123.sig', + }).importToken, + ).toBe('batch.123.sig') }) it('requires vote intention consent acceptance', () => { From 9ccfb3b7049325870dd3c58907432cab1689cda8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sat, 18 Jul 2026 22:34:55 -0300 Subject: [PATCH 2/2] Simplify C6 supporter paths and register C8 scale/DRY follow-ups. Apply the /simplify cleanup to the C6 commit (identity maps, dead branches, relationshipId reuse, native base64url, rowCount fix, canManageCampaignUsers reuse) and register the unimplemented debits as roadmap item C8 with its plan. --- docs/plans/escala-dry-pos-c6.md | 120 ++++++++++++++++++ docs/roadmap.md | 36 +++--- .../(campaign)/campanha/actions/supporter.ts | 22 +--- src/collections/Contact.ts | 2 +- src/collections/SupporterImportBatch.ts | 25 ++-- .../campaign/SupporterImportWizard.tsx | 6 +- src/utilities/campaignFormActionError.ts | 5 +- src/utilities/supporterImport.ts | 3 + src/utilities/supporterImportBulk.ts | 45 +++---- src/utilities/supporterImportToken.ts | 30 ++--- .../supporterListOverviewAggregate.ts | 4 +- 11 files changed, 188 insertions(+), 110 deletions(-) create mode 100644 docs/plans/escala-dry-pos-c6.md diff --git a/docs/plans/escala-dry-pos-c6.md b/docs/plans/escala-dry-pos-c6.md new file mode 100644 index 00000000..f03b1a51 --- /dev/null +++ b/docs/plans/escala-dry-pos-c6.md @@ -0,0 +1,120 @@ +# Escala e DRY pós-C6 (apoiadores / import / listas) + +Status: rascunho +Atualizado em: 2026-07-19 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C8) +Responsável: — + +## Contexto + +O C6 ([escala-dry-pos-c2.md](escala-dry-pos-c2.md)) entregou a escala do cadastro nominal de apoiadores: import bulk via drizzle na txn Payload, KPI agregado em SQL, token HMAC de import single-use, e shells compartilhados (`campaignListUrl`, `CampaignListPagination`, `campaignFormFields`, `mapCampaignFormActionError`). A passagem `/simplify` de 2026-07-19 aplicou limpezas pontuais (identity maps, dead branches, `relationshipId`, base64url nativo, `rowCount ?? 0`, reuso de `canManageCampaignUsers`) mas **deixou de fora** débitos que os revisores (quality / reuse / performance) marcaram como importantes e maiores que cleanup. Este plano é o registro canônico desses follow-ups — precedente direto de B5 (pós-B2) e C7 (pós-C3). + +Sem este item, a importação funciona em volume baixo, mas: (a) o gargalo de import em escala volta a ser a aquisição de locks advisory (um round-trip por telefone), não o insert bulk; (b) a página de lista de apoiadores faz dois COUNTs filtrados por navegação; (c) a busca por nome/cidade é `ILIKE '%q%'` não-sargable; (d) o scaffold de tipos drizzle do bulk apaga a tipagem de colunas (já causou um bug `contactId` → `contact`); (e) o helper de erro de form adotado só por 2 forms permanece duplicado em ~6 `formActions` legados. + +## Objetivos + +- Import bulk de 5k apoiadores adquire todos os locks advisory de telefone em **um** round-trip (hoje até 5k round-trips sequenciais). +- O insert bulk de contacts recupera IDs via `.returning()` (hoje re-`payload.find` por chunk). +- Leituras de existência (contacts/supporters existentes) no bulk usam drizzle direto na mesma txn, sem o scaffolding do Local API. +- A página de lista de apoiadores não faz dois COUNTs filtrados por navegação; o `total` do overview vem do `totalDocs` da lista, ou o overview é cacheado por `(user, filterState)` com TTL curto. +- Busca por `q` em nome/cidade usa índice `pg_trgm` GIN (ou restringe a prefix/telefone) — deixa de ser seq scan por página. +- O lookup de núcleos acessíveis do coordenador é memoizado por request (lista + overview compartilham uma query). +- `chunk` / `requireTable` / `INSERT_CHUNK_SIZE` / scaffold de tipos drizzle vivem num helper compartilhado, reusado por `supporterImportBulk.ts` e `electionResultsImport.ts` (import TSE). +- O scaffold de tipos drizzle não apaga a tipagem de colunas: assertion runtime dos nomes de coluna em `payload.db.tables.supporter` ao init **ou** `PostgresTransactionDatabase` expõe um `insert` tipado. +- `getAccessibleNucleusIds` ganha uma variante sem `PayloadRequest` (ou `supporterListOverviewAggregate` reusa a existente), eliminando o lookup duplicado de escopo coordenador. +- Todos os `formActions.ts` de `/campanha` restantes migram para `mapCampaignFormActionError` + `campaignFormFields` (hoje duplicados em ~6 arquivos). +- Guardrails: `overrideAccess: false` com `user`; escritas multi-step continuam em `withPayloadTransaction`; locks advisory continuam xact-level; sem novo `Consent`; sem mudança de comportamento visível. + +## Decisões travadas + +- **Um item C8, fases ordenadas.** Mesmo racional do C6/C7: um ID de roadmap, PRs por fase. Ordem: perf do caminho quente (import) → perf de leitura (lista) → DRY de helpers → DRY de forms. +- **Dependência dura de C6 (merge).** O código que C8 otimiza é o código do C6; não reabre o escopo de v1 do `supporter`/`supporterImportBatch`. +- **Cortável se a base nominal permanecer pequena.** Fases 1–2 (perf de import/lista) só rendem com base real em volume; se a base ficar pequena até 16/08, adiar para Janela 3. Fases 3–4 (DRY) são baratas e reduzem drift — manter se houver folga. +- **Sem migration de schema em Fases 1–3.** Fase 2 pode exigir uma migration pequena (`pg_trgm` GIN em `contact.name`/`contact.city`) — única migration do item. +- **`postgresTransactionLocks.ts` é pré-existente e compartilhado** com o path single-create (`upsertContactByPhone`, `enforceUniqueContactPhone`). A Fase 1 troca o loop de locks por uma query única sem mudar o contrato `acquireContactPhoneLocks(payload, req, phones[])` — callers não mudam. +- **i18n e naming** (AGENTS.md): identificadores em inglês (`acquireContactPhoneLocks` mantido, `drizzleBulk.ts`, `getAccessibleNucleusIds` variant, etc.), strings visíveis em pt-BR. + +## Questões em aberto + +- **`pg_trgm` vs prefix-only para busca de nome?** `ILIKE '%q%'` precisa de trigram para ser sargable; prefix-only (`ILIKE 'q%'`) usa btree mas perde busca infix. **Recomendação:** `pg_trgm` GIN (a busca infix é o comportamento esperado pela UI hoje); migration pequena `add_contact_trgm_index`. +- **Overview `total` derivado do `totalDocs` da lista vs cache?** Derivar elimina um COUNT mas acopla overview ao carregamento da lista (overview some se lista não carrega). **Recomendação:** derivar `total` de `result.totalDocs` (já computado pela lista) e rodar só os dois `FILTER` no overview; cache só se houver reclamação de latency. +- **Assertion runtime de colunas vs widen `PostgresTransactionDatabase`?** Assertion é barata e pega o bug na inicialização; widen é mais limpo mas toca `postgresTransactionLocks.ts` (pré-existente). **Recomendação:** assertion runtime em `supporterImportBulk.ts` (escopo do item); widen fica como follow-up de plataforma. +- **Migrar formActions de planos aqui (C8) ou em C7?** O plano do C6 anotou "mapper de form actions de planos ficou para C7". **Recomendação:** consolidar TODOS os formActions restantes em C8 Fase 4 (C8 é o "DRY pós-C6" e C6 introduziu os helpers); C7 Fase 3 continua dono dos helpers FormData/URL de território do actionPlan. Atualizar a nota cruzada no plano do C6 na hora do PR. + +## Abordagem proposta + +```mermaid +flowchart TD + F1["Fase 1 — Perf import
locks em 1 RT · .returning · drizzle reads"] + F2["Fase 2 — Perf lista
1 COUNT · trgm · memo accessible nuclei"] + F3["Fase 3 — DRY helpers
drizzleBulk · getAccessibleNucleusIds variant · DrizzleTx assertion"] + F4["Fase 4 — DRY forms
migrar formActions restantes"] + + C6["C6 apoiadores ✓"] --> F1 + F1 --> F2 + F2 --> F3 + F3 --> F4 +``` + +### Fase 1 — Perf do caminho quente de import + +- **`acquireContactPhoneLocks`** (`src/utilities/contactPhoneInvariant.ts` / `postgresTransactionLocks.ts`): trocar o loop `for (const key of sortedKeys) await database.execute(...)` por uma query única `SELECT pg_advisory_xact_lock(hashtextextended(k, 0)) FROM unnest(ARRAY[...]) AS k` (ordenar para evitar deadlock). Contrato `acquireContactPhoneLocks(payload, req, phones[])` inalterado — callers (bulk, single-create, hook) não mudam. +- **`bulkInsertSupporterImport`** (`src/utilities/supporterImportBulk.ts`): `tx.insert(contactTable).values(...).returning({ id: true, phone: true })` em vez do re-`payload.find` por chunk para recuperar IDs (elimina ~10 round-trips no cap de 5k). Widen o tipo `DrizzleTx` local para tipar `.returning()`. +- Substituir os dois `payload.find` de existência (contacts por phone, supporters por contact_id) por `SELECT ... FROM contact WHERE phone = ANY($)` / `SELECT contact_id FROM supporter WHERE contact_id = ANY($) AND nucleus_id IS NULL` na mesma sessão drizzle da txn. + +### Fase 2 — Perf de leitura da lista + +- **`loadSupporterListOverviewData`** (`src/utilities/supporterPageData.ts`): derivar `total` do `result.totalDocs` da lista (já computado por Payload); o overview roda só os dois `COUNT(*) FILTER` (certo+tende, indeciso). Elimina um COUNT filtrado completo por navegação. +- **`computeSupporterListOverviewAggregate`** (`src/utilities/supporterListOverviewAggregate.ts`): memoizar o `resolveAccessConstraint` (lookup de núcleos acessíveis do coordenador) por request — lista e overview compartilham a mesma query `electoralNucleus`. +- **Migration** `add_contact_trgm_index`: `CREATE EXTENSION IF NOT EXISTS pg_trgm` + `CREATE INDEX ... USING gin (name gin_trgm_ops)` e `(city gin_trgm_ops)` em `contact`. Torna `ILIKE '%q%'` sargable. Única migration do item. +- Avaliar restringir `q` a mínimo 2–3 chars no cliente (reduz seq scans inúteis) — opcional, sem migration. + +### Fase 3 — DRY de helpers + +- **`src/utilities/drizzleBulk.ts`** (novo): extrair `chunk`, `requireTable`, `INSERT_CHUNK_SIZE`, e o scaffold de tipos `DrizzleTx`/`PayloadDb` de `supporterImportBulk.ts` e `electionResultsImport.ts` (hoje byte-idênticos). Importar de ambos. +- **`getAccessibleNucleusIds`** (`src/utilities/campaignAccess.ts`): adicionar variante sem `PayloadRequest` (ou parametrizar a existente com cache opcional) para que `supporterListOverviewAggregate` reuse em vez de re-implementar o lookup `coordinators contains user.id`. +- **Type safety do bulk:** assertion runtime em `supporterImportBulk.ts` que `Object.keys(payload.db.tables.supporter)` contém as colunas esperadas (`contact`, `nucleus`, `consent`, `createdBy`, …) no init do módulo — pega o bug `contactId` vs `contact` antes do runtime. (Widen de `PostgresTransactionDatabase` fica como follow-up de plataforma.) + +### Fase 4 — DRY de forms + +- Migrar os `formActions.ts` restantes para `mapCampaignFormActionError` + `campaignFormFields` (hoje o ladder `FormDataBoundaryError` → `ZodError` → `validationFieldErrors` → safe-message está inlined em ~6 arquivos): + - `src/app/(campaign)/campanha/(app)/nucleos/formActions.ts` + - `src/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions.ts` + - `src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions.ts` + - `src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions.ts` + - `src/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions.ts` + - `src/app/(campaign)/campanha/convite/[token]/formActions.ts` + - `src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts` + - `src/app/(campaign)/campanha/(app)/planos/formActions.ts` e `planos/[slug]/updateFormActions.ts` +- Migrar `fieldError`/`firstError` local em `src/components/campaign/NucleusUpdateForm.tsx` e `CampaignInviteForm.tsx` para importar de `campaignFormFields.ts`. + +**Migration:** Fases 1, 3, 4 sem schema. Fase 2: `pnpm migrate:create add_contact_trgm_index` (extensão + 2 GIN indexes). Sem Consent novo. + +## Dependências + +- **Dura:** C6 Escala e DRY pós-C2 (código em `supporterImportBulk.ts` / `supporterListOverviewAggregate.ts` / `supporterPageData.ts` / `campaignFormActionError.ts`). +- Reusa: `src/utilities/contactPhoneInvariant.ts`, `postgresTransactionLocks.ts`, `getPostgresTransactionDatabase`, `relationshipId`, `campaignAccess.ts` (`getAccessibleNucleusIds`, `canManageCampaignUsers`), `electionResultsImport.ts` (para `drizzleBulk.ts`), C6 plano [escala-dry-pos-c2.md](escala-dry-pos-c2.md). + +## Não escopo + +- Reabrir decisões de v1 do C6 (token HMAC vs opaque, `supporterImportBatch` como staging, `skipContactPhoneInvariant` fail-closed) — [escala-dry-pos-c2.md](escala-dry-pos-c2.md). +- Helpers FormData/URL de território do actionPlan — C7 Fase 3 / [escala-dry-pos-c3.md](escala-dry-pos-c3.md). +- Camada de zonas no mapa, coroplético — B3/B4. +- Widen de `PostgresTransactionDatabase` para expor `insert` tipado — follow-up de plataforma (a assertion runtime da Fase 3 cobre o bug). +- Notificações / push — D2. + +## Referências + +- `docs/roadmap.md` — Trilha C, item C8; sequência Janela 2 +- [escala-dry-pos-c2.md](escala-dry-pos-c2.md) — C6 entregue; precedente do formato +- [escala-dry-pos-b2.md](escala-dry-pos-b2.md) — precedente B5 (pós-B2 simplify) +- [escala-dry-pos-c3.md](escala-dry-pos-c3.md) — precedente C7 (pós-C3 simplify) +- Review `/simplify` 2026-07-19 (quality / reuse / performance) — origem das fases +- `src/utilities/supporterImportBulk.ts` — bulk insert (locks, .returning, drizzle reads) +- `src/utilities/postgresTransactionLocks.ts` — `acquireContactPhoneLocks` (loop por telefone) +- `src/utilities/supporterListOverviewAggregate.ts` — aggregate SQL + `resolveAccessConstraint` +- `src/utilities/supporterPageData.ts` — `loadSupporterListOverviewData` (duplo COUNT) +- `src/utilities/electionResultsImport.ts` — `chunk`/`requireTable`/`INSERT_CHUNK_SIZE` duplicados +- `src/utilities/campaignAccess.ts` — `getAccessibleNucleusIds` +- `src/utilities/campaignFormActionError.ts` / `campaignFormFields.ts` — helpers adotados por 2 forms +- AGENTS.md — `overrideAccess: false`, transações, locks advisory xact-level, naming diff --git a/docs/roadmap.md b/docs/roadmap.md index bde596e0..dfe13aab 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado (Fases 1–5) — aguarda deploy; C7 registrado — escala/DRY pós-C3; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`) +Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado (Fases 1–5) — aguarda deploy; C7 registrado — escala/DRY pós-C3; C8 registrado — escala/DRY pós-C6 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`) Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos. @@ -86,6 +86,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig | C6 Escala e DRY pós-C2 | — (sem design novo; reusa os de C2 + padrões de núcleos) | [escala-dry-pos-c2.md](plans/escala-dry-pos-c2.md) | | C3 Eventos / agenda de mobilização | `Planos-de-Acao`, `Novo-Plano-de-Acao` | [eventos-agenda-mobilizacao.md](plans/eventos-agenda-mobilizacao.md) | | C7 Escala e DRY pós-C3 | — (sem design novo; reusa os de C3 + padrões de núcleos/C6) | [escala-dry-pos-c3.md](plans/escala-dry-pos-c3.md) | +| C8 Escala e DRY pós-C6 | — (sem design novo; reusa os de C2/C6) | [escala-dry-pos-c6.md](plans/escala-dry-pos-c6.md) | | B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) | | D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) | | D1 PWA `/campanha` | `Notificacoes-PWA` (banner "Instale o app da campanha") | [pwa-campanha.md](plans/pwa-campanha.md) | @@ -127,6 +128,7 @@ flowchart TD C6["C6 Escala e DRY pós-C2
(import/listas/forms)"] C3["C3 Eventos / agenda de mobilização ✓"] C7["C7 Escala e DRY pós-C3
(território/contato/leituras)"] + C8["C8 Escala e DRY pós-C6
(locks bulk / leituras / forms DRY)"] C4["C4 Demandas"] C5["C5 Operação dia D / GOTV
(proposto, a validar)"] end @@ -160,11 +162,12 @@ flowchart TD C2 --> C6 C2 --> C5 C6 -.escala da base.-> C5 + C6 --> C8 D1 --> D2 JUR -.chave de push.-> D2 ``` -Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, B2, C1, C2, C3 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A4 (A2/A3 + B1 prontos), B3 (B1 ✓ + B2 ✓; suave: A4 para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C6 (merge de C2 ✓), C7 (após C3; suave: C6 F2) e D2 push (D1 pronto; falta a chave jurídica). +Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, B2, C1, C2, C3 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A4 (A2/A3 + B1 prontos), B3 (B1 ✓ + B2 ✓; suave: A4 para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C6 (merge de C2 ✓), C7 (após C3; suave: C6 F2), C8 (merge de C6 ✓; cortável se a base nominal permanecer pequena) e D2 push (D1 pronto; falta a chave jurídica). ### Sequência de execução por janela do calendário @@ -185,29 +188,30 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (vi | ----- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------- | ----------------- | | 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta e mesclada em `main` 2026-07-18 — deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3, C6, C7 | | 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) **(implementado 2026-07-19 — Fases 1–5; aguarda deploy)** | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3, C7 | -| 9 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7 | -| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7 | -| 11 | C7 Escala e DRY pós-C3 (território/contato compostos, leituras por aba, RMW/índice) | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6 | +| 9 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7, C8 | +| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7, C8 | +| 11 | C7 Escala e DRY pós-C3 (território/contato compostos, leituras por aba, RMW/índice) | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8 | +| 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7 | **Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.** | Ordem | Item | Plano | Depende de | Paralelizável com | | ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------- | -| 12 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | B3, B5, C4, D1 | -| 13 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; B5 F1 lazy geometrias) | A5, B5, C4, D1 | -| 14 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, B3, C4, D1 | -| 15 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, B3, B5, D1 | -| 16 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo | -| 17 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, B3, B5, C4 | +| 13 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | B3, B5, C4, D1 | +| 14 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; B5 F1 lazy geometrias) | A5, B5, C4, D1 | +| 15 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, B3, C4, D1 | +| 16 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, B3, B5, D1 | +| 17 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo | +| 18 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, B3, B5, C4 | **Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.** | Ordem | Item | Plano | Depende de | | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| 18 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) | -| 19 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 | -| 20 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) | -| 21 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. | — | — | +| 19 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) | +| 20 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 | +| 21 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) | +| 22 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. | — | — | **Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):** @@ -216,7 +220,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (vi - **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_ - **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_ -**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 (leituras/RMW/índice — só se a agenda permanecer pequena), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2 e C7 fases 1–2 (DRY de UI) se houver folga. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço. +**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 (leituras/RMW/índice — só se a agenda permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2 e C8 fases 3–4 (DRY de helpers/forms) se houver folga. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço. ### Itens consolidados/removidos nesta revisão (2026-07-17) diff --git a/src/app/(campaign)/campanha/actions/supporter.ts b/src/app/(campaign)/campanha/actions/supporter.ts index 965a7a7d..93f2fded 100644 --- a/src/app/(campaign)/campanha/actions/supporter.ts +++ b/src/app/(campaign)/campanha/actions/supporter.ts @@ -32,6 +32,7 @@ import { verifySupporterImportToken, } from '@/utilities/supporterImportToken' import { + isPreviewErrorRow, isSupporterImportOkRow, type SupporterImportPreviewResult, type SupporterImportPreviewRow, @@ -50,18 +51,6 @@ const MAX_IMPORT_ROWS = 5000 /** On-screen preview is capped; the full ok set is staged server-side (Phase 5). */ const SUPPORTER_IMPORT_SAMPLE_SIZE = 100 -const isPreviewErrorRow = (row: SupporterImportPreviewRow): boolean => - row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone' - -const importStatusCsvValue: Record = { - ok: 'ok', - duplicado_pelo_telefone: 'duplicado_pelo_telefone', - telefone_invalido: 'telefone_invalido', - municipio_nao_reconhecido: 'municipio_nao_reconhecido', - nome_invalido: 'nome_invalido', - intencao_invalida: 'intencao_invalida', -} - const escapeCsvCell = (value: string): string => `"${value.replace(/"/g, '""')}"` const buildSupporterImportErrorReportCsv = (rows: SupporterImportPreviewRow[]): string => { @@ -70,7 +59,7 @@ const buildSupporterImportErrorReportCsv = (rows: SupporterImportPreviewRow[]): .filter(isPreviewErrorRow) .map( (row) => - `${row.line},${escapeCsvCell(row.nome)},${escapeCsvCell(row.telefone)},${escapeCsvCell(row.municipio)},${escapeCsvCell(row.intencao)},${importStatusCsvValue[row.status]}`, + `${row.line},${escapeCsvCell(row.nome)},${escapeCsvCell(row.telefone)},${escapeCsvCell(row.municipio)},${escapeCsvCell(row.intencao)},${row.status}`, ) .join('\n') return header + body @@ -600,12 +589,7 @@ export const confirmSupporterImportRecord = async ( payload, req, actorID: currentActor.id, - rows: batch.okRows.map((row) => ({ - telefone: row.telefone, - nome: row.nome, - municipio: row.municipio, - intencao: row.intencao, - })), + rows: batch.okRows, registrationConsent, voteIntentionConsent, consentNote, diff --git a/src/collections/Contact.ts b/src/collections/Contact.ts index 87f7c0e0..3b3ef5f9 100644 --- a/src/collections/Contact.ts +++ b/src/collections/Contact.ts @@ -22,7 +22,7 @@ const enforceUniqueContactPhone: CollectionBeforeChangeHook = async ({ // honored inside an active transaction — without one the xact-level advisory // lock cannot be held, so we run the full check regardless of the caller's claim. if (req.context?.skipContactPhoneInvariant === true) { - if (req.transactionID === undefined || req.transactionID === null) { + if (req.transactionID == null) { throw new APIError( 'skipContactPhoneInvariant exige uma transação ativa com os locks de telefone já adquiridos.', 500, diff --git a/src/collections/SupporterImportBatch.ts b/src/collections/SupporterImportBatch.ts index b4582f3f..c3613a02 100644 --- a/src/collections/SupporterImportBatch.ts +++ b/src/collections/SupporterImportBatch.ts @@ -1,6 +1,6 @@ -import type { Access, CollectionConfig } from 'payload' +import type { CollectionConfig } from 'payload' -import { getFreshCampaignUser, isCampaignGeneral, isPayloadAdmin } from '@/utilities/campaignAccess' +import { canManageCampaignUsers } from '@/utilities/campaignAccess' /** * Short-lived server-side staging for the supporter CSV import wizard (roadmap @@ -10,16 +10,11 @@ import { getFreshCampaignUser, isCampaignGeneral, isPayloadAdmin } from '@/utili * Action boundary twice. * * The collection is hidden from the admin nav and restricted to `geral` / - * Payload-admin; the import actions interact with it via the Local API with - * `overrideAccess: true`. Rows hold transient supporter PII and must be - * purged on consume or after `expiresAt` (lazy cleanup + sweep). + * Payload-admin (same policy as `campaignUser` management); the import actions + * interact with it via the Local API with `overrideAccess: true`. Rows hold + * transient supporter PII and must be purged on consume or after `expiresAt` + * (lazy cleanup + sweep). */ -const canAccessImportBatch: Access = async ({ req }) => { - if (isPayloadAdmin(req.user)) return true - const fresh = await getFreshCampaignUser(req) - return isCampaignGeneral(fresh) -} - export const SupporterImportBatch: CollectionConfig = { slug: 'supporterImportBatch', labels: { @@ -33,10 +28,10 @@ export const SupporterImportBatch: CollectionConfig = { defaultColumns: ['batchId', 'actor', 'expiresAt'], }, access: { - create: canAccessImportBatch, - read: canAccessImportBatch, - update: canAccessImportBatch, - delete: canAccessImportBatch, + create: canManageCampaignUsers, + read: canManageCampaignUsers, + update: canManageCampaignUsers, + delete: canManageCampaignUsers, }, fields: [ { diff --git a/src/components/campaign/SupporterImportWizard.tsx b/src/components/campaign/SupporterImportWizard.tsx index e265134b..f32c3699 100644 --- a/src/components/campaign/SupporterImportWizard.tsx +++ b/src/components/campaign/SupporterImportWizard.tsx @@ -29,6 +29,7 @@ import { Textarea } from '@/components/ui/textarea' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup' import { cn } from '@/lib/utils' import { + isPreviewErrorRow, type SupporterImportPreviewResult, type SupporterImportPreviewRow, } from '@/utilities/supporterImport' @@ -45,9 +46,6 @@ const importStatusLabels: Record = const steps = ['Upload', 'Conferir prévia', 'Confirmação'] as const -const isPreviewErrorRow = (row: SupporterImportPreviewRow): boolean => - row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone' - const downloadErrorReport = (errorReportCsv: string) => { const blob = new Blob([errorReportCsv], { type: 'text/csv;charset=utf-8' }) const url = URL.createObjectURL(blob) @@ -196,7 +194,7 @@ export const SupporterImportWizard = () => { type="button" variant="outline" className="min-h-11" - onClick={() => preview && downloadErrorReport(preview.errorReportCsv)} + onClick={() => downloadErrorReport(preview.errorReportCsv)} > Baixar relatório de erros diff --git a/src/utilities/campaignFormActionError.ts b/src/utilities/campaignFormActionError.ts index 7d6ec28d..077af937 100644 --- a/src/utilities/campaignFormActionError.ts +++ b/src/utilities/campaignFormActionError.ts @@ -30,10 +30,7 @@ export const mapCampaignFormActionError = ( if (error instanceof ZodError) { return { fieldErrors: validationFieldErrors(error), values, revision } } - if ( - error instanceof Error && - safeMessages.includes(error.message as (typeof safeMessages)[number]) - ) { + if (error instanceof Error && safeMessages.includes(error.message)) { return { message: error.message, values, revision } } return { message: genericMessage, values, revision } diff --git a/src/utilities/supporterImport.ts b/src/utilities/supporterImport.ts index df25283e..9a07cc69 100644 --- a/src/utilities/supporterImport.ts +++ b/src/utilities/supporterImport.ts @@ -49,3 +49,6 @@ export type SupporterImportPreviewResult = { export const isSupporterImportOkRow = ( row: SupporterImportPreviewRow, ): row is SupporterImportOkRow => row.status === 'ok' + +export const isPreviewErrorRow = (row: SupporterImportPreviewRow): boolean => + row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone' diff --git a/src/utilities/supporterImportBulk.ts b/src/utilities/supporterImportBulk.ts index 75b37079..b4ece1e5 100644 --- a/src/utilities/supporterImportBulk.ts +++ b/src/utilities/supporterImportBulk.ts @@ -6,6 +6,7 @@ import type { SupporterVoteIntention } from '@/lib/schemas/supporter' import type { ConsentDescriptor } from '@/utilities/campaignConsent' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { getPostgresTransactionDatabase } from '@/utilities/postgresTransactionLocks' +import { relationshipId } from '@/utilities/relationship' export type SupporterImportBulkRow = { telefone: string @@ -45,7 +46,7 @@ const INSERT_CHUNK_SIZE = 500 const chunk = (rows: readonly T[], size: number): T[][] => { const out: T[][] = [] - for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size) as T[]) + for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size)) return out } @@ -187,30 +188,21 @@ export const bulkInsertSupporterImport = async (args: { } const contactIDs = [...new Set(contactIdByPhone.values())] - const existingSupporters = - contactIDs.length > 0 - ? await payload.find({ - collection: 'supporter', - where: { - and: [{ contact: { in: contactIDs } }, { nucleus: { exists: false } }], - }, - depth: 0, - limit: contactIDs.length, - pagination: false, - select: { contact: true }, - overrideAccess: true, - req, - }) - : null + const existingSupporters = await payload.find({ + collection: 'supporter', + where: { + and: [{ contact: { in: contactIDs } }, { nucleus: { exists: false } }], + }, + depth: 0, + limit: contactIDs.length, + pagination: false, + select: { contact: true }, + overrideAccess: true, + req, + }) const supporterContactIDs = new Set( - (existingSupporters?.docs ?? []) - .map((doc) => - typeof doc.contact === 'number' - ? doc.contact - : typeof doc.contact === 'object' && doc.contact !== null - ? doc.contact.id - : null, - ) + existingSupporters.docs + .map((doc) => relationshipId(doc.contact)) .filter((id): id is number => id !== null), ) @@ -236,7 +228,6 @@ export const bulkInsertSupporterImport = async (args: { let created = 0 for (const batch of chunk(candidateEntries, INSERT_CHUNK_SIZE)) { - if (batch.length === 0) continue const result = await tx .insert(supporterTable) .values( @@ -249,7 +240,9 @@ export const bulkInsertSupporterImport = async (args: { }), ) .onConflictDoNothing() - created += Number(result.rowCount ?? batch.length) + // rowCount reflects inserted rows only (conflicts are skipped). Fall back to 0 + // rather than batch.length so a null rowCount never silently over-counts. + created += Number(result.rowCount ?? 0) } return { created, skipped, errors } diff --git a/src/utilities/supporterImportToken.ts b/src/utilities/supporterImportToken.ts index fbb8a5c8..ef62dc77 100644 --- a/src/utilities/supporterImportToken.ts +++ b/src/utilities/supporterImportToken.ts @@ -14,7 +14,7 @@ export type SupporterImportOkRow = { intencao?: SupporterVoteIntention } -export type SupporterImportBatch = { +export type StagedSupporterImportBatch = { actorID: number expiresAt: number okRows: SupporterImportOkRow[] @@ -27,7 +27,7 @@ const IMPORT_TOKEN_TTL_MS = 10 * 60 * 1000 export const storeSupporterImportBatch = async ( payload: Payload, batchId: string, - batch: SupporterImportBatch, + batch: StagedSupporterImportBatch, req?: PayloadTransactionRequest, ): Promise => { await payload.create({ @@ -49,7 +49,7 @@ export const loadSupporterImportBatch = async ( batchId: string, expectedActorID: number, req?: PayloadTransactionRequest, -): Promise => { +): Promise => { const result = await payload.find({ collection: BATCH_COLLECTION, where: { batchId: { equals: batchId } }, @@ -60,21 +60,15 @@ export const loadSupporterImportBatch = async ( req, }) const doc = result.docs[0] as - | { id: number; actor: unknown; expiresAt?: string; rows?: unknown } + | { id: number; actor: number; expiresAt: string; rows: unknown } | undefined if (!doc) throw new Error('Lote de importação não encontrado ou expirado.') - const actorID = - typeof doc.actor === 'number' - ? doc.actor - : typeof doc.actor === 'object' && doc.actor !== null && 'id' in doc.actor - ? (doc.actor as { id: number }).id - : null - if (actorID !== expectedActorID) { + if (doc.actor !== expectedActorID) { throw new Error('Lote de importação inválido.') } - const expiresAtMs = doc.expiresAt ? Date.parse(doc.expiresAt) : NaN + const expiresAtMs = Date.parse(doc.expiresAt) if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) { throw new Error('Lote de importação expirado.') } @@ -114,14 +108,6 @@ export const deleteSupporterImportBatch = async ( }) } -const base64url = (buffer: Buffer): string => - buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') - -const base64urlToBuffer = (value: string): Buffer => { - const padded = value.replace(/-/g, '+').replace(/_/g, '/') - return Buffer.from(padded, 'base64') -} - const getSecret = (): string => { const secret = process.env.PAYLOAD_SECRET if (!secret) throw new Error('PAYLOAD_SECRET não configurado para o token de importação.') @@ -140,7 +126,7 @@ export type SignedImportToken = { export const issueSupporterImportToken = (actorID: number): SignedImportToken => { const batchId = randomUUID() const expiresAt = Date.now() + IMPORT_TOKEN_TTL_MS - const sig = base64url(signature(batchId, actorID, expiresAt)) + const sig = signature(batchId, actorID, expiresAt).toString('base64url') return { token: `${batchId}.${expiresAt}.${sig}`, batchId, expiresAt } } @@ -156,7 +142,7 @@ export const verifySupporterImportToken = ( throw new Error('Token de importação expirado.') } const expected = signature(batchId, actorID, expiresAt) - const provided = base64urlToBuffer(sig) + const provided = Buffer.from(sig, 'base64url') if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) { throw new Error('Token de importação inválido.') } diff --git a/src/utilities/supporterListOverviewAggregate.ts b/src/utilities/supporterListOverviewAggregate.ts index 6550546c..32fbe155 100644 --- a/src/utilities/supporterListOverviewAggregate.ts +++ b/src/utilities/supporterListOverviewAggregate.ts @@ -46,9 +46,7 @@ const resolveAccessConstraint = async ( overrideAccess: true, }) - const ids = result.docs - .map((doc) => (typeof doc.id === 'number' ? doc.id : Number(doc.id))) - .filter((id) => Number.isSafeInteger(id)) + const ids = result.docs.map((doc) => doc.id) return { kind: 'nucleusSet', ids } }