Add C2 nominal supporter registry for /campanha.

Ship the supporter join collection, consent-keyed create/import/remove actions, and apoiadores UI so staff can build a phone-required nominal base before mass field ops.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-18 20:32:54 -03:00
parent 3b703d42a3
commit b079daa9bb
39 changed files with 12651 additions and 112 deletions

View file

@ -34,6 +34,7 @@ alwaysApply: false
- **C1 Compartilhar núcleo** — `ShareNucleusDialogShell` no header do detalhe; destinatários carregados sob demanda por Server Action de leitura (`loadNucleusShareRecipients`), não no DTO da página. Loader confirma acesso ao núcleo (`overrideAccess: false`) e então lê nome+telefone com `overrideAccess: true` (evita N+1 de field access); lideranças só `engajado`; seção de lideranças omitida para o papel `lideranca`; `canReadCampaignUserPhone` passou a liberar telefone de `geral` para qualquer usuário de campanha. Compartilhar envia só o link — não cria convite nem concede acesso.
- **D1 PWA** — manifest + SW servidos por route handlers sob `/campanha` (script em `campaignPwa.ts`), cache `campanha-<buildId>` com `skipWaiting`/`claim`, navegação network-first com fallback `/campanha/offline`, RSC/Flight e `/campanha/convite/*` jamais cacheados; ícones em `public/campaign-icons/`; `InstallPwaToast` (Android `beforeinstallprompt`, iOS Drawer, dispensa em `sessionStorage`); logout limpa Cache API no cliente antes da action.
- **A3 Baseline TSE 2022 Fase 1** — collections `electionTally`/`electionCandidateVote`/`electionCandidate` (grupo `Dados Eleitorais`, leitura para `campaignUser`/`users` via `canReadElectionData`, escrita admin-only), migration `20260718_195854_add_election_results`, seed local `pnpm db:seed:tse` (idempotente por escopo year/office/turn, delete+bulk insert via drizzle, guard de banco local). Sem PII (sem CPF/título); identidade cross-ano por `identityKey` hash. UI fica no A4.
- **Em 2026-07-18, C2 Cadastro nominal de apoiadores — engenharia pronta (pendente merge/deploy + lote jurídico).** Collection `supporter` (join `Contact`↔campanha, núcleo opcional), migration `20260718_222656_add_supporter` com `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent por chaves `apoiador-cadastro` / `apoiador-intencao-voto` via `campaignConsent.ts` genérico, actions (create / intenção / import CSV só `geral` / remover dados), UI `/campanha/apoiadores` (lista+KPIs, ficha, wizard import, kit mínimo `wa.me`). Telefone obrigatório; `lideranca` sem acesso à área. Produção com dados reais espera Consent keys + aprovação jurídica (Onda 0).
## Decisões

View file

@ -1,8 +1,8 @@
# Cadastro nominal de apoiadores em massa
Status: rascunho
Atualizado em: 2026-07-17
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos", linha 56)
Status: engenharia pronta (pendente merge/deploy + lote jurídico)
Atualizado em: 2026-07-18
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
Responsável: —
## Referência visual (UX Pilot)
@ -21,10 +21,10 @@ Três designs cobrem este plano:
Como usar:
- **Lista — adotar:** KPIs no topo (total / "Certo + Tende" / "Indecisos"), busca por nome/telefone/município, filtros "Intenção de voto" e "Território", linhas com nome + município + núcleo vinculado (ou "sem núcleo vinculado" — reflete a decisão de núcleo opcional) + badge de intenção + telefone; estado "Sem telefone cadastrado" em itálico; CTAs "Importar CSV" e "+ Novo".
- **Ficha — adotar:** é a tela que melhor materializa as decisões LGPD do plano: bloco destacado "Consentimento LGPD" com checkbox nominal, link para a Política de Privacidade e registro "Consentimento registrado em … · Coletor: …" (`consentedAt` + `createdBy`); segmented control de intenção de voto **desabilitado até o consentimento** ("Disponível somente após confirmação do consentimento LGPD" — consentimento destacado, chave `apoiador-intencao-voto`); seção "Kit de compartilhamento" com mensagem pronta, "Enviar no WhatsApp"/"Copiar texto" e a nota "A mensagem é enviada pelo celular de {nome} — não é disparo em massa da campanha" (art. 33 §2º); ação "Remover meus dados" (descadastramento art. 18).
- **Import — adotar:** wizard em 3 passos (Upload → Conferir prévia → Confirmação) com chips de contagem ("412 prontos" / "37 duplicados (pelo telefone)" / "3 erros"), tabela de prévia com status por linha (`ok`, `duplicado pelo telefone`, `telefone inválido`, `município não reconhecido`), toggle "Todos | Só erros", "Baixar relatório de erros" e resumo final. Corresponde ao `importSupporters` (dedup por telefone + preview de erros por linha). O sidebar desktop do design mostra a navegação-alvo (Início, Núcleos, Lideranças, Apoiadores, Planos de ação, Atualizações, Territórios) — usar como referência do shell desktop, mas só adicionar entradas de domínios que existirem.
- **Ajustar cores:** paleta antiga (navy/vermelho escuro) no HTML/PNG; implementar com tokens do tema `campaign` (sidebar clara `#FAFAF9`, primário `#C51414`, badges de intenção com os pares pastel claros). Avatares com foto viram iniciais; "solla.ba" no kit é placeholder — a URL real vem de `NEXT_PUBLIC_SITE_URL`.
- **Lista — adotar:** KPIs no topo (total / "Certo + Tende" / "Indecisos"), busca por nome/telefone/município, filtros "Intenção de voto" e "Território", linhas com nome + município + núcleo vinculado (ou "sem núcleo vinculado" — reflete a decisão de núcleo opcional) + badge de intenção + telefone; CTAs "Importar CSV" e "+ Novo". (Telefone é obrigatório no v1 — ver decisões fechadas; o estado "Sem telefone cadastrado" do design não se aplica.)
- **Ficha — adotar:** bloco destacado "Consentimento LGPD" com checkbox nominal, link para a Política de Privacidade e registro "Consentimento registrado em … · Coletor: …" (`consentedAt` + `createdBy`); segmented control de intenção de voto **desabilitado até o consentimento** ("Disponível somente após confirmação do consentimento LGPD" — consentimento destacado, chave `apoiador-intencao-voto`); seção "Kit de compartilhamento" mínimo (mensagem template + `wa.me` + copiar texto; sem collection `shareKit`); ação "Remover meus dados" (descadastramento art. 18).
- **Import — adotar:** wizard em 3 passos (Upload → Conferir prévia → Confirmação) com chips de contagem ("412 prontos" / "37 duplicados (pelo telefone)" / "3 erros"), tabela de prévia com status por linha (`ok`, `duplicado pelo telefone`, `telefone inválido`, `município não reconhecido`), toggle "Todos | Só erros", "Baixar relatório de erros" e resumo final. Import só por `geral`. O sidebar desktop do design mostra a navegação-alvo — usar como referência do shell, mas só adicionar entradas de domínios que existirem (sem "Agenda"/C3).
- **Ajustar cores:** paleta antiga (navy/vermelho escuro) no HTML/PNG; implementar com tokens do tema `campaign`. Avatares com foto viram iniciais; "solla.ba" no kit é placeholder — a URL real vem de `NEXT_PUBLIC_SITE_URL`.
## Contexto
@ -38,7 +38,7 @@ Pesquisa web (2026-07-17). Fontes: TSE (Res. 23.610/2019 alterada pela 23.732/20
- **Opinião política e filiação partidária são dados sensíveis** (art. 11 da LGPD). Não cabe legítimo interesse do controlador; exigem **consentimento específico, expresso e destacado**. A intenção de voto capturada neste plano é dado sensível.
- **Princípio da finalidade** (art. 6º LGPD): dado coletado para "cadastro de apoio" ou "lista de presença em evento" não migra livremente para "propaganda eleitoral". Cada finalidade nova precisa ser consentida. O `source` (proveniência) e o `Consent` por finalidade documentam isso.
- **Disparo em massa é vedado** no WhatsApp/Telegram (Res. 23.610, art. 33). Permitido só compartilhamento individual/orgânico entre pessoas físicas (art. 33, §2º). A Meta veda o uso do WhatsApp Business API por campanhas políticas.
- **Disparo em massa é vedado** no WhatsApp/Telegram (Res. 23.610, art., 33). Permitido só compartilhamento individual/orgânico entre pessoas físicas (art. 33, §2º). A Meta veda o uso do WhatsApp Business API por campanhas políticas.
- **Vedada** a cessão, doação e venda de cadastros eletrônicos a candidatas/candidatos/partidos. A base nominal construída aqui é da campanha do Solla e não é compartilhada.
- **Obrigações do controlador**: Aviso de Privacidade, Política de Segurança, Canal de Comunicação (confirmação de tratamento, eliminação, descadastramento — art. 18 LGPD), Encarregado de Dados (DPO; dispensado em municípios < 200 mil eleitores), Registro das Operações de Tratamento (art. 33-C da Res. 23.610).
- **RIPD**: a Justiça Eleitoral pode exigir Relatório de Impacto à Proteção de Dados quando o tratamento for em larga escala (≥10% do eleitorado apto da circunscrição) e envolver dado sensível ou perfilamento. Avaliação caso a caso pela assessoria jurídica.
@ -50,7 +50,7 @@ Pesquisa web (2026-07-17). Fontes: TSE (Res. 23.610/2019 alterada pela 23.732/20
- Import em massa (CSV) com dedup por telefone, transacional, whitelist de colunas e preview de erros por linha.
- Captura de **intenção de voto** (dado sensível) com `Consent` por chave estável `apoiador-intencao-voto`, falha fechado se ausente.
- Consentimento de apoio declarado por chave `apoiador-cadastro`.
- **Mobilização orgânica**: kits de conteúdo compartilhável (links `wa.me` pré-preenchidos, cards, textos) para apoiadores repassarem individualmente em seus círculos sociais — sem disparo em massa pela campanha.
- **Mobilização orgânica**: kit mínimo de compartilhamento (links `wa.me` pré-preenchidos + texto template) — sem disparo em massa pela campanha e sem collection `shareKit` no v1.
- Canal de descadastramento/eliminação (direito LGPD art. 18).
- Access control por papel herdando o padrão existente (`getAccessibleNucleusIds`).
@ -59,42 +59,43 @@ Pesquisa web (2026-07-17). Fontes: TSE (Res. 23.610/2019 alterada pela 23.732/20
- **Pessoa = `Contact` + junção.** Nova collection `supporter` relaciona `contact` (required) + `nucleus` (opcional) + campos de apoio. Nunca criar "apoiador/pessoa" paralela. (AGENTS.md.)
- **Híbrido com núcleo opcional.** Apoiador pode existir sem núcleo (base transversal); quando vinculado a um núcleo, agrega por território. `nucleus` é `relationship` opcional a `electoralNucleus`.
- **`leadership` segue separada.** Liderança é o apoiador engajado com acesso ao app; `leadership` permanece responsável pelo `campaignUser` e pelo `supportStatus` interno. Um hook em `supporter` impede o mesmo `contact` ser apoiador e liderança do mesmo núcleo simultaneamente.
- **Intenção de voto é dado sensível.** Captura exige `Consent.key = 'apoiador-intencao-voto'` (consentimento destacado, separado do consentimento de cadastro). Falha fechado se a chave não existir — mesmo padrão do convite de liderança (`campaignInvite.ts`).
- **Import em massa só por `geral`/admin**, via server action autenticada no `/campanha` (não no `/admin`, que ainda não tem RBAC — AGENTS Known Gap #1). Dedup por telefone com `pg_advisory_xact_lock(hashtextextended(phone, 0))` (mesmo padrão do `Contact`). Tudo transacional com `req: { transactionID }`.
- **Sem disparo em massa.** A campanha não envia mensagens em massa a apoiadores. Fornece **kits de compartilhamento** (links `wa.me` pré-preenchidos, cards, textos) que o apoiador/envia individualmente aos seus contatos. Caminho legal (art. 33, §2º).
- **Descadastramento.** Server action `unsubscribeSupporter` (por token ou por telefone autenticado) anonimiza/remove o `supporter` e registra o pedido, preservando contagem agregada sem PII.
- **Access control.** `geral` vê/gerencia todos; `coordenador` vê apoiadores dos seus núcleos; `lideranca` não gerencia apoiadores (só vê o próprio cadastro se for apoiador). Herda `getAccessibleNucleusIds`.
- **i18n e naming.** Identificadores em inglês (`supporter`, `voteIntention`, `unsubscribeSupporter`), strings visíveis em pt-BR. Admin group `Campanha`.
- **Telefone obrigatório no v1 (2026-07-18).** `Contact.phone` continua required/único; linha de import sem telefone válido é erro. O estado "sem telefone" do design não se aplica.
- **Intenção de voto é dado sensível.** Captura exige `Consent.key = 'apoiador-intencao-voto'` (consentimento destacado, separado do consentimento de cadastro). Falha fechado se a chave não existir — resolver genérico em `campaignConsent.ts` (`getConsentByKey` / `requireConsentByKey`).
- **Import em massa só por `geral`/admin**, via server action autenticada no `/campanha` (não no `/admin`, que ainda não tem RBAC — AGENTS Known Gap #1). Dedup por telefone via `acquireContactPhoneLocks` / `assertContactPhoneAvailable` em `contactPhoneInvariant.ts` (não criar `supporterDedup.ts`). Tudo transacional com `req: { transactionID }`.
- **Kit de compartilhamento mínimo no v1 (2026-07-18).** Template fixo + `wa.me` + copiar texto no detalhe do apoiador; sem collection `shareKit`.
- **Descadastramento.** Server action `removeSupporterData` deleta o `supporter` e anonimiza/limpa PII do `Contact` apenas se nenhum outro join (`leadership` / `signature` / `subscription` / outro `supporter`) o referencia.
- **Access control.** `geral` vê/gerencia todos; `coordenador` vê apoiadores dos seus núcleos (`nucleus in getAccessibleNucleusIds`); apoiador sem núcleo é só de `geral`; `lideranca` não gerencia apoiadores. Delete admin-only.
- **Enum de intenção.** `certo | tende_a_certo | indeciso | outro` (bate com o design). Campo `supportLevel` fica fora do v1.
- **i18n e naming.** Identificadores em inglês (`supporter`, `voteIntention`, `removeSupporterData`), strings visíveis em pt-BR. Admin group `Campanha`.
## Questões em aberto
## Questões fechadas (2026-07-18)
- **Coexistência `supporter`↔`leadership` no mesmo núcleo.** Recomendação: um contato não pode ser apoiador e liderança do mesmo núcleo simultaneamente (hook valida). Definir com produto.
- **Import no `/campanha` vs `/admin`.** Recomendação: import no `/campanha` (server action autenticada, `geral`), com preview de dedup e erros por linha — mantém dentro da ferramenta de campanha e fora do `/admin` sem RBAC. Definir com produto.
- **Intenção de voto: enum ou escala.** Recomendação: enum `certo | tende_a_certo | indeciso | outro` + campo de observação. Definir com produto/jurídico.
- **RIPD necessário?** Se a base nominal superar 10% do eleitorado apto + dado sensível, TSE pode exigir RIPD. Recomendação: assessoria jurídica avalia; este plano entrega a base, não o RIPD.
- **Kit de compartilhamento: onde mora?** Recomendação: novo domínio `shareKit` (cards + texto + link) relacionado a `post`/evento, consumido no detalhe do apoiador e no dashboard. Pode ser fase 2. Definir com produto.
- **Anonimização vs eliminação.** LGPD art. 18 permite eliminação. Recomendação: descadastramento anonimiza `Contact.phone`/`email` e remove `supporter`, preservando contagem agregada sem PII. Definir com jurídico.
- **Reuso da base na pós-eleição.** Dados coletados em campanha não migram para mandato sem nova base legal. Recomendação: documentar retenção e expiração no Aviso de Privacidade. Definir com jurídico.
- **Coexistência `supporter`↔`leadership` no mesmo núcleo.** Adotado: um contato não pode ser apoiador e liderança do mesmo núcleo (hook valida).
- **Import no `/campanha` vs `/admin`.** Adotado: import no `/campanha` (server action, só `geral`).
- **Intenção de voto: enum.** Adotado: `certo | tende_a_certo | indeciso | outro`.
- **Kit de compartilhamento.** Adotado no v1: versão mínima (template + `wa.me` + copiar); collection `shareKit` fora de escopo.
- **Telefone.** Adotado: obrigatório; linha sem telefone válido = erro no import.
- **Anonimização vs eliminação.** Adotado (assumido — validar com jurídico): deleta `supporter`; limpa PII do `Contact` só se nenhum outro join o referencia.
- **Atestado do operador no import CSV.** Adotado (assumido — validar com jurídico): checkbox do operador registrado em `consentNote`/`consentedAt`, exige as duas chaves de Consent configuradas.
- **RIPD / reuso pós-eleição.** Continuam com jurídico; fora do escopo de engenharia deste item.
## Abordagem proposta
```mermaid
flowchart LR
Import["Import CSV / cadastro manual<br/>(server action, geral)"]
Dedup["pg_advisory_xact_lock<br/>hashtextextended(phone, 0)"]
Dedup["contactPhoneInvariant<br/>advisory lock"]
Contact["upsert Contact<br/>(transacional)"]
Consent["resolve Consent por chave<br/>apoiador-cadastro / apoiador-intencao-voto"]
Consent["resolve Consent por chave<br/>campaignConsent.ts"]
Supporter["create supporter<br/>contact + nucleus opcional<br/>+ voteIntention + consent"]
Agg["agregados por território/setor<br/>+ insights (conversão, gap vs 2022)"]
Kit["kit de compartilhamento<br/>wa.me + cards + textos"]
Share["apoiador repassa<br/>individualmente (art. 33 §2º)"]
Unsub["unsubscribeSupporter<br/>anonimiza/remove"]
Kit["kit mínimo<br/>wa.me + texto"]
Share["apoiador repassa<br/>individualmente"]
Unsub["removeSupporterData<br/>deleta / anonimiza"]
Import --> Dedup
Dedup --> Contact
Contact --> Consent
Consent --> Supporter
Supporter --> Agg
Supporter --> Kit
Kit --> Share
Supporter --> Unsub
@ -105,71 +106,67 @@ Componentes:
- **`src/collections/Supporter.ts`** (nova, group `Campanha`):
- `contact` (relationship→`contact`, required, index)
- `nucleus` (relationship→`electoralNucleus`, opcional, index)
- `supportLevel` (select: `apoiador | simpatizante | a_abordar`, default `apoiador`, index)
- `voteIntention` (select: `certo | tende_a_certo | indeciso | outro`, index) — **dado sensível**, field access restrito a `geral`/`coordenador`
- `consent` (relationship→`consent`) — consentimento de intenção/cadastro
- `consentContentHash`, `consentedAt` (mesmo padrão do `leadership`)
- `source` (select: `import_csv | manual | convite | evento`) — proveniência para o Registro de Operações (art. 33-C)
- `notes` (textarea, internal, field access restrito)
- `createdBy` (relationship→`campaignUser`, readOnly, index)
- indexes: unique `[contact, nucleus]` quando núcleo definido; index `[contact]` para apoiadores sem núcleo.
- access: `canReadSupporter`, `canManageSupporter`, `canCreateSupporter` (em `campaignAccess.ts`).
- hook `beforeChange`: valida coexistência com `leadership` (rejeita se o `contact` já é `leadership` do mesmo `nucleus`).
- `voteIntention` (select: `certo | tende_a_certo | indeciso | outro`, index) — **dado sensível**, field access restrito a `geral`/`coordenador` (reusa `canReadLeadershipInternal`)
- Pares de consentimento: `consent`/`consentContentHash`/`consentedAt` (cadastro) e `voteIntentionConsent`/`voteIntentionConsentContentHash`/`voteIntentionConsentedAt` (intenção)
- `source` (select: `import_csv | manual | convite | evento`) — v1 grava só os dois primeiros
- `consentNote`, `notes` (staff-only), `createdBy` (readOnly)
- Unicidade: `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)` na migration (Postgres ≥15)
- access: `canReadSupporter`, `canManageSupporter`, `canCreateSupporter` (em `campaignAccess.ts`)
- hook `beforeChange`: seta `createdBy`; rejeita `contact` que já é `leadership` do mesmo `nucleus`
- **`Consent` keys** (admin cadastra; app falha fechado se ausente):
- `apoiador-cadastro` — apoio declarado (dado não-sensível).
- `apoiador-intencao-voto` — intenção de voto (dado sensível, consentimento destacado).
- **`src/utilities/supporterConsent.ts`** — resolve `Consent` por chave (mesmo padrão do convite em `campaignInvite.ts`).
- **`src/app/(campaign)/campanha/actions/supporter.ts`** — server actions:
- `importSupporters` (parse CSV, dedup, transacional, `geral` only, preview de erros por linha).
- `setSupporterVoteIntention` (exige consentimento destacado ativo).
- `unsubscribeSupporter` (descadastramento/eliminação).
- **`src/utilities/supporterDedup.ts`** — reusa o lock `pg_advisory_xact_lock(hashtextextended(phone, 0))`.
- **UI**:
- `/campanha/apoiadores` — lista paginada com filtros por território/setor/intenção (`geral`/`coordenador`).
- Import no `/campanha` com preview de dedup e erros.
- Kit de compartilhamento no detalhe do apoiador (fase 2).
- **Migrations**: `pnpm migrate:create add_supporter` (+ `add_supporter_consent_keys` se necessário). Tipos: `pnpm generate:types`.
- `apoiador-cadastro` — apoio declarado
- `apoiador-intencao-voto` — intenção de voto (consentimento destacado)
- **`src/utilities/campaignConsent.ts`** — generalizar com `getConsentByKey` / `requireConsentByKey`; wrappers de liderança e constantes `SUPPORTER_REGISTRATION_CONSENT_KEY` / `SUPPORTER_VOTE_INTENTION_CONSENT_KEY`
- **`src/lib/schemas/supporter.ts`** — zod schemas (create, intenção, import, filtros)
- **`src/app/(campaign)/campanha/actions/supporter.ts`** — `createSupporter`, `setSupporterVoteIntention`, `previewSupporterImport` / `confirmSupporterImport`, `removeSupporterData`
- **UI** `/campanha/apoiadores` — lista + ficha + wizard de import; nav "Apoiadores" só para geral/coordenador
- **Migrations**: `pnpm migrate:create add_supporter`. Tipos: `pnpm generate:types`
## O que isso viabiliza
- **Base nominal por território/setor** para direcionar campo e eventos.
- **Intenção de voto nominal** alimentando os insights existentes (taxa de conversão, gap vs. 2022, classificação territorial, dobradinha 2026) com dado real em vez de só estimativa manual — cruzar com o baseline TSE 2022 (plano `baseline-eleitoral-tse.md`).
- **Base nominal por território** para direcionar campo e eventos.
- **Intenção de voto nominal** alimentando insights (conversão, gap vs. 2022) com dado real.
- **Mobilização orgânica**: apoiadores compartilham conteúdo da campanha em seus círculos (caminho legal, sem disparo em massa).
- **Dobradinha 2026**: identificar apoiadores de aliados por território.
- **Demandas/eventos**: rotear convites e demandas por território (quando esses domínios existirem).
- **Agregados no overview da lista de núcleos** (plano `overview-lista-nucleos.md`) passam a incluir contagem nominal de apoiadores.
- **Agregados no overview da lista de núcleos** (follow-up de B1) passam a incluir contagem nominal de apoiadores.
## Dependências
- Nenhuma bloqueante. Reusa `Contact`, `Consent` (por chave), `campaignAccess` (`getAccessibleNucleusIds`), `normalizeBrazilianPhone`, o lock de dedup e o padrão transacional multi-collection.
- **Insights** (taxa de conversão, gap vs. 2022, classificação territorial) se beneficiam, mas não bloqueiam.
- **Baseline TSE 2022** (plano separado) habilita o cruzamento nominal vs. voto 2022.
- Nenhuma bloqueante de engenharia. Reusa `Contact`, `Consent` (por chave via `campaignConsent.ts`), `campaignAccess` (`getAccessibleNucleusIds`), `normalizeBrazilianPhone`, `contactPhoneInvariant`, e o padrão transacional multi-collection.
- **Baseline TSE 2022** habilita o cruzamento nominal vs. voto 2022 (fora deste item).
## Não escopo
- Disparo em massa / WhatsApp Business API — vedado por lei e pela Meta.
- Compartilhamento/cessão da base com partido/outras candidaturas — vedado.
- RIPD — entregue pela assessoria jurídica, não por este plano.
- PWA/push para apoiadores — escopo do plano `notifications.md` (interna à equipe).
- Disparo em massa / WhatsApp Business API — vedado.
- Collection `shareKit` completa — kit mínimo no v1 basta.
- RIPD — assessoria jurídica.
- PWA/push para apoiadores — plano `notifications.md`.
- Agregado de apoiadores no overview de núcleos — follow-up de B1.
- Uso da base no dia D — C5.
- Campo `supportLevel` — fora do v1.
- Mesclar `leadership` em `supporter` — `leadership` segue responsável pelo acesso ao app.
## Bloqueador obrigatório de produção
Antes de importar dados reais ou capturar intenção de voto: a assessoria jurídica eleitoral documenta a base do art. 11 da LGPD para intenção de voto (dado sensível) e dos arts. 7/8 para o cadastro de apoio, aprova os textos versionados específicos, e um admin cadastra `Consent.key = 'apoiador-cadastro'` e `Consent.key = 'apoiador-intencao-voto'`. O app falha fechado se qualquer uma das chaves estiver ausente — mesmo padrão do bloqueador de liderança.
**Lote jurídico único (decisão de processo 2026-07-17):** esses dois textos devem ser revisados na **mesma rodada jurídica** que o texto de `lideranca-autopreenchimento` (bloqueador do MVP de Núcleos) e o de `campanha-notificacoes-push` ([notifications.md](notifications.md)) — quatro textos, uma rodada. O jurídico é o caminho crítico de produção da vertical inteira; fatiar em rodadas separadas multiplica o lead time externo. A mesma rodada deve cobrir o **Aviso de Privacidade** (página institucional LGPD, bloqueador já listado no roadmap), que é obrigação do controlador antes de coleta em massa.
**Lote jurídico único (decisão de processo 2026-07-17):** esses dois textos devem ser revisados na **mesma rodada jurídica** que o texto de `lideranca-autopreenchimento` (bloqueador do MVP de Núcleos) e o de `campanha-notificacoes-push` ([notifications.md](notifications.md)) — quatro textos, uma rodada. A mesma rodada deve cobrir o **Aviso de Privacidade**.
**Nota de faseamento:** a engenharia deste plano **não** fica bloqueada pelo jurídico — o app falha fechado por design, então collection, import, UI e testes podem ser construídos e deployados antes das chaves existirem; só o uso com dados reais espera a aprovação.
**Nota de faseamento:** a engenharia deste plano **não** fica bloqueada pelo jurídico — o app falha fechado por design.
## Revisões
- **2026-07-18:** auditoria pré-implementação C2. Corrigidas refs defasadas (`campaignConsent.ts` em vez de `campaignInvite.ts` / `supporterConsent.ts`; `contactPhoneInvariant.ts` em vez de `supporterDedup.ts`). Decisões fechadas: telefone obrigatório; kit mínimo no v1; enum de intenção; coexistência com leadership; import só `geral`; `supportLevel` fora do v1; `removeSupporterData` com anonimização condicionada a outros joins.
## Referências
- [`docs/roadmap.md`](../roadmap.md) (linha 56)
- [`docs/roadmap.md`](../roadmap.md)
- [`AGENTS.md`](../../AGENTS.md) — Pessoa = `Contact` + junção; `Consent` por chave; transações multi-collection; access control
- Res. TSE 23.610/2019 (alterada pela 23.732/2024); LGPD art. 11 (dados sensíveis) e art. 18 (direitos do titular)
- [`src/collections/Leadership.ts`](../../src/collections/Leadership.ts) — padrão de junção única `Contact`↔núcleo + consent
- [`src/collections/Leadership.ts`](../../src/collections/Leadership.ts) — padrão de junção `Contact`↔núcleo + consent
- [`src/collections/Signature.ts`](../../src/collections/Signature.ts) e [`src/collections/Subscription.ts`](../../src/collections/Subscription.ts) — padrão de join com `Contact`
- [`src/collections/Consent.ts`](../../src/collections/Consent.ts) — `key` estável
- [`src/collections/Contact.ts`](../../src/collections/Contact.ts) — normalização de telefone
- [`src/utilities/campaignAccess.ts`](../../src/utilities/campaignAccess.ts) — `getAccessibleNucleusIds`, padrão de access por papel
- [`src/utilities/campaignInvite.ts`](../../src/utilities/campaignInvite.ts) — `Consent` por chave + falha fechada
- [`src/app/(frontend)/actions/submitWhatsapp.ts`](<../../src/app/(frontend)/actions/submitWhatsapp.ts>) — padrão transacional multi-collection
- [`src/utilities/campaignAccess.ts`](../../src/utilities/campaignAccess.ts) — `getAccessibleNucleusIds`
- [`src/utilities/campaignConsent.ts`](../../src/utilities/campaignConsent.ts) — resolve Consent por chave + falha fechada
- [`src/utilities/contactPhoneInvariant.ts`](../../src/utilities/contactPhoneInvariant.ts) — advisory lock de telefone
- [`src/app/(campaign)/campanha/actions/leadership.ts`](<../../src/app/(campaign)/campanha/actions/leadership.ts>) — upsert de Contact + transação

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue: cadastro município↔ZE + chips opt-in no formulário de território)
Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue: cadastro município↔ZE + chips opt-in; C2 engenharia pronta — merge/deploy + Consent keys jurídicos ainda pendentes)
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.
@ -161,7 +161,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (enge
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------- | ----------------- |
| 7 | C2 Cadastro nominal de apoiadores (a peça central pré-16/08; engenharia não espera o jurídico — o app falha fechado) | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3 |
| 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta 2026-07-18 — merge/deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3 |
| 8 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3 |
| 9 | C3 Eventos / agenda de mobilização (`actionPlan`) | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4 |

View file

@ -0,0 +1,89 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { ZodError } from 'zod'
import {
removeSupporterData,
setSupporterVoteIntention,
} from '@/app/(campaign)/campanha/actions/supporter'
import {
checkboxFormValue,
FormDataBoundaryError,
requiredRelationshipFormValue,
validationFieldErrors,
} from '@/lib/formData'
import { supporterRemoveSchema, supporterVoteIntentionSchema } from '@/lib/schemas/supporter'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
export type SupporterVoteIntentionFormState = {
status?: 'success'
message?: string
voteIntention?: SupporterVoteIntention
}
export type SupporterRemoveFormState = {
status?: 'success'
message?: string
}
const safeVoteIntentionMessages = [
'Consentimento de intenção de voto ainda não configurado.',
'Somente a coordenação pode gerenciar apoiadores.',
] as const
export const setSupporterVoteIntentionFormAction = async (
_state: SupporterVoteIntentionFormState,
formData: FormData,
): Promise<SupporterVoteIntentionFormState> => {
try {
const input = supporterVoteIntentionSchema.parse({
id: requiredRelationshipFormValue(formData, 'id'),
voteIntention: formData.get('voteIntention'),
voteIntentionConsentAccepted: checkboxFormValue(formData, 'voteIntentionConsentAccepted')
? true
: undefined,
})
const supporter = await setSupporterVoteIntention(input)
revalidatePath('/campanha/apoiadores/[id]', 'page')
return {
status: 'success',
message: 'Intenção de voto atualizada.',
voteIntention: supporter.voteIntention ?? undefined,
}
} catch (error) {
if (error instanceof FormDataBoundaryError) {
return { message: error.message }
}
if (error instanceof ZodError) {
const fieldErrors = validationFieldErrors(error)
return { message: fieldErrors.voteIntention?.[0] ?? fieldErrors.form?.[0] }
}
if (
error instanceof Error &&
safeVoteIntentionMessages.includes(error.message as (typeof safeVoteIntentionMessages)[number])
) {
return { message: error.message }
}
return { message: 'Não foi possível salvar a intenção de voto.' }
}
}
export const removeSupporterDataFormAction = async (
_state: SupporterRemoveFormState,
formData: FormData,
): Promise<SupporterRemoveFormState> => {
try {
const input = supporterRemoveSchema.parse({
id: requiredRelationshipFormValue(formData, 'id'),
})
await removeSupporterData(input)
revalidatePath('/campanha/apoiadores')
redirect('/campanha/apoiadores')
} catch (error) {
if (error instanceof Error && error.message === 'NEXT_REDIRECT') throw error
return { message: 'Não foi possível remover os dados do apoiador.' }
}
}

View file

@ -0,0 +1,28 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/Empty'
export default function SupporterNotFoundPage() {
return (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyTitle>Apoiador não encontrado</EmptyTitle>
<EmptyDescription>
O registro pode ter sido removido ou você não tem acesso a ele.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/apoiadores">Voltar para apoiadores</Link>
</Button>
</EmptyContent>
</Empty>
)
}

View file

@ -0,0 +1,177 @@
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import config from '@payload-config'
import { getPayload } from 'payload'
import { ArrowLeftIcon, ShieldCheckIcon } from 'lucide-react'
import {
setSupporterVoteIntentionFormAction,
} from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { ConsentText } from '@/components/campaign/ConsentText'
import { RemoveSupporterDataButton } from '@/components/campaign/RemoveSupporterDataButton'
import { SupporterShareKit } from '@/components/campaign/SupporterShareKit'
import { VoteIntentionControl } from '@/components/campaign/VoteIntentionControl'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { isCampaignGeneral } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
loadSupporterDetailConsentData,
loadSupporterDetailPageData,
SupporterNotFoundError,
} from '@/utilities/supporterPageData'
import { parseSupporterId } from '@/utilities/supporterViewModels'
import { canAccessSupporterArea, supporterVoteIntentionLabels } from '@/utilities/supporterUi'
type SupporterDetailPageProps = {
params: Promise<{ id: string }>
}
const dateFormatter = new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'short',
timeStyle: 'short',
})
export default async function SupporterDetailPage({ params }: SupporterDetailPageProps) {
const { id: rawId } = await params
const supporterId = parseSupporterId(rawId)
if (supporterId === null) notFound()
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (!canAccessSupporterArea(user.role)) redirect('/campanha')
let supporter
let consentData
try {
;[supporter, consentData] = await Promise.all([
loadSupporterDetailPageData(payload, user, supporterId),
loadSupporterDetailConsentData(payload),
])
} catch (error) {
if (error instanceof SupporterNotFoundError) notFound()
throw error
}
const voteIntentionLabel = supporter.voteIntention
? supporterVoteIntentionLabels[supporter.voteIntention]
: null
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, '') ?? 'https://jorgesolla.com.br'
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="w-fit px-0">
<Link href="/campanha/apoiadores">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para apoiadores
</Link>
</Button>
<CampaignScopeBadge>
{isCampaignGeneral(user) ? 'Coordenação geral' : 'Coordenação de núcleo'}
</CampaignScopeBadge>
<h1 className="text-2xl font-bold tracking-tight">{supporter.name}</h1>
<p className="text-muted-foreground">
{supporter.city ?? 'Município não informado'}
{supporter.territory ? ` · ${supporter.territory}` : ''}
</p>
</header>
<Card>
<CardHeader>
<CardTitle className="text-base">Resumo</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<p>
Cadastrado em {dateFormatter.format(new Date(supporter.createdAt))}
{supporter.createdByName ? ` · por ${supporter.createdByName}` : ''}
</p>
<p>Origem: {supporter.sourceLabel}</p>
{voteIntentionLabel ? (
<Badge variant="estimate-confirmed" className="w-fit">
{voteIntentionLabel}
</Badge>
) : (
<Badge variant="outline" className="w-fit">
Sem intenção registrada
</Badge>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Dados de contato</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<p className="tabular-nums">{supporter.phoneDisplay || '—'}</p>
{supporter.email ? <p>{supporter.email}</p> : null}
<p>
{supporter.nucleusName ? (
<>
Núcleo:{' '}
<Link
href={`/campanha/nucleos/${supporter.nucleusSlug}`}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{supporter.nucleusName}
</Link>
</>
) : (
<span className="text-muted-foreground">Sem núcleo vinculado</span>
)}
</p>
</CardContent>
</Card>
<section className="rounded-[6px] border-2 border-primary/20 bg-primary/5 p-4">
<div className="flex items-start gap-2">
<ShieldCheckIcon className="mt-0.5 size-5 text-primary" aria-hidden="true" />
<div className="flex flex-col gap-3">
<div>
<h2 className="font-medium">Consentimento LGPD</h2>
<p className="text-sm text-muted-foreground">
Registro do consentimento de cadastro coletado no momento do cadastro.
</p>
</div>
{consentData.registrationConsent ? (
<div className="max-h-48 overflow-y-auto rounded-[6px] border bg-background p-3 text-sm">
<ConsentText data={consentData.registrationConsent} />
</div>
) : (
<p className="text-sm text-muted-foreground">
Texto de consentimento não configurado no admin.
</p>
)}
{supporter.consentedAt ? (
<p className="text-sm text-muted-foreground">
Consentimento registrado em {dateFormatter.format(new Date(supporter.consentedAt))}
{supporter.createdByName ? ` · Coletor: ${supporter.createdByName}` : ''}
</p>
) : null}
<p className="text-sm">
<Link href="/privacidade" className="text-primary underline-offset-4 hover:underline">
Política de Privacidade
</Link>
</p>
</div>
</div>
</section>
<VoteIntentionControl
supporterId={supporter.id}
currentValue={supporter.voteIntention}
hasVoteIntentionConsent={supporter.hasVoteIntentionConsent}
voteIntentionConsentConfigured={Boolean(consentData.voteIntentionConsent)}
action={setSupporterVoteIntentionFormAction}
/>
<SupporterShareKit supporterName={supporter.name} siteUrl={siteUrl} phone={supporter.phone} />
<RemoveSupporterDataButton supporterId={supporter.id} />
</div>
)
}

View file

@ -0,0 +1,32 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { SupporterImportWizard } from '@/components/campaign/SupporterImportWizard'
import { Button } from '@/components/ui/button'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { isCampaignGeneral } from '@/utilities/campaignAccess'
export default async function ImportSupportersPage() {
const user = await getCampaignUser()
if (!user) redirect('/campanha/login')
if (!isCampaignGeneral(user)) redirect('/campanha/apoiadores')
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="w-fit px-0">
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
</Button>
<CampaignScopeBadge>Importação · Coordenação geral</CampaignScopeBadge>
<h1 className="text-2xl font-bold tracking-tight">Importar apoiadores via CSV</h1>
<p className="text-muted-foreground">
Envie uma planilha, confira a prévia e confirme a importação em lote.
</p>
</header>
<SupporterImportWizard />
</div>
)
}

View file

@ -0,0 +1,115 @@
'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 { sanitizeBrazilianPhoneInput } from '@/utilities/phone'
export type SupporterFormState = {
status?: 'success'
message?: string
fieldErrors?: Record<string, string[]>
supporterId?: number
values?: SupporterFormValues
revision?: number
}
export type SupporterFormValues = {
name?: string
phone?: string
email?: string
city?: string
nucleus?: string
voteIntention?: string
}
const safeActionMessages = [
'Esta pessoa já está cadastrada como apoiador neste núcleo.',
'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.',
'Somente a coordenação pode gerenciar apoiadores.',
'Somente a coordenação geral pode cadastrar apoiadores sem núcleo.',
'Consentimento de cadastro de apoiador ainda não configurado.',
'Consentimento de intenção de voto ainda não configurado.',
] as const
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.',
values,
revision,
}
}
export const createSupporterFormAction = async (
state: SupporterFormState,
formData: FormData,
): Promise<SupporterFormState> => {
let values: SupporterFormValues | undefined
const revision = (state.revision ?? 0) + 1
try {
values = {
name: optionalFormText(formData, 'name'),
phone: optionalFormText(formData, 'phone'),
email: optionalFormText(formData, 'email'),
city: optionalFormText(formData, 'city'),
nucleus: optionalFormText(formData, 'nucleus'),
voteIntention: optionalFormText(formData, 'voteIntention'),
}
const phone = sanitizeBrazilianPhoneInput(values.phone ?? '')
const voteIntention = values.voteIntention?.trim() || undefined
const nucleus = nullableRelationshipFormValue(formData, 'nucleus')
const input = supporterCreateSchema.parse({
name: values.name ?? '',
phone,
email: values.email,
city: values.city,
...(nucleus ? { nucleus } : {}),
...(voteIntention ? { voteIntention } : {}),
consentAccepted: checkboxFormValue(formData, 'consentAccepted') ? true : undefined,
voteIntentionConsentAccepted: voteIntention
? checkboxFormValue(formData, 'voteIntentionConsentAccepted')
: undefined,
})
const supporter = await createSupporter(input)
revalidatePath('/campanha/apoiadores')
return {
status: 'success',
message: supporter.contactReused
? 'Apoiador cadastrado. O contato existente com este celular foi reutilizado.'
: 'Apoiador cadastrado com sucesso.',
supporterId: supporter.id,
}
} catch (error) {
return getSupporterFormError(error, values, revision)
}
}

View file

@ -0,0 +1,47 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import config from '@payload-config'
import { getPayload } from 'payload'
import { createSupporterFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { SupporterForm } from '@/components/campaign/SupporterForm'
import { Button } from '@/components/ui/button'
import { isCampaignGeneral } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadSupporterCreatePageData } from '@/utilities/supporterPageData'
import { canAccessSupporterArea } from '@/utilities/supporterUi'
export default async function NewSupporterPage() {
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (!canAccessSupporterArea(user.role)) redirect('/campanha')
const pageData = await loadSupporterCreatePageData(payload, user)
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="w-fit px-0">
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
</Button>
<CampaignScopeBadge>
{isCampaignGeneral(user) ? 'Coordenação geral' : 'Coordenação de núcleo'}
</CampaignScopeBadge>
<h1 className="text-2xl font-bold tracking-tight">Novo apoiador</h1>
<p className="text-muted-foreground">
Cadastre um apoiador com telefone obrigatório e consentimento LGPD.
</p>
</header>
<SupporterForm
action={createSupporterFormAction}
nucleusOptions={pageData.nucleusOptions}
registrationConsentConfigured={pageData.registrationConsentConfigured}
voteIntentionConsentConfigured={pageData.voteIntentionConsentConfigured}
requireNucleus={pageData.requireNucleus}
/>
</div>
)
}

View file

@ -0,0 +1,127 @@
import { FileUpIcon, PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import config from '@payload-config'
import { getPayload } from 'payload'
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,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { isCampaignGeneral } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
loadAccessibleNucleusOptions,
loadSupporterListOverviewData,
loadSupporterListPageData,
} from '@/utilities/supporterPageData'
import { toSupporterListItemViewModel } from '@/utilities/supporterViewModels'
import {
buildSupporterFiltersKey,
canAccessSupporterArea,
getSupporterScopeLabel,
resolveSupporterListUrl,
} from '@/utilities/supporterUi'
type SupportersPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function SupportersPage({ searchParams }: SupportersPageProps) {
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) return null
if (!canAccessSupporterArea(user.role)) redirect('/campanha')
const rawSearchParams = await searchParams
const provisional = resolveSupporterListUrl(rawSearchParams)
const [{ result, state, redirectHref }, overview, nucleusOptions] = await Promise.all([
loadSupporterListPageData(payload, user, rawSearchParams),
loadSupporterListOverviewData(payload, user, provisional.state),
loadAccessibleNucleusOptions(payload, user),
])
if (redirectHref) redirect(redirectHref)
return (
<div className="mr-auto flex w-full max-w-screen-2xl flex-col gap-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold tracking-tight">Apoiadores</h1>
<p className="text-muted-foreground">
Base nominal de apoio com intenção de voto e vínculo opcional a núcleos.
</p>
<CampaignScopeBadge>
{isCampaignGeneral(user)
? 'Coordenação geral · todos os apoiadores'
: getSupporterScopeLabel(result.totalDocs)}
</CampaignScopeBadge>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{isCampaignGeneral(user) ? (
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/apoiadores/importar">
<FileUpIcon data-icon="inline-start" aria-hidden="true" />
Importar CSV
</Link>
</Button>
) : null}
<Button asChild className="min-h-11">
<Link href="/campanha/apoiadores/novo">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo
</Link>
</Button>
</div>
</header>
<SupporterFilters
key={buildSupporterFiltersKey(state)}
state={state}
nucleusOptions={nucleusOptions}
/>
{result.docs.length ? (
<>
{overview ? <SupporterListOverview view={overview} /> : null}
<SupporterList
supporters={result.docs.map((supporter) => toSupporterListItemViewModel(supporter))}
/>
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-muted-foreground">
{result.totalDocs}{' '}
{result.totalDocs === 1 ? 'apoiador encontrado' : 'apoiadores encontrados'}
</p>
<SupporterPagination state={state} totalPages={result.totalPages} />
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhum apoiador encontrado</EmptyTitle>
<EmptyDescription>
Ajuste a busca ou os filtros. Você só vê apoiadores dentro do seu escopo.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/apoiadores">Limpar busca e filtros</Link>
</Button>
</EmptyContent>
</Empty>
)}
</div>
)
}

View file

@ -0,0 +1,788 @@
'use server'
import { parse } from 'csv-parse/sync'
import type { Payload } from 'payload'
import {
resolveBahiaMunicipality,
supporterCreateSchema,
supporterImportConfirmSchema,
supporterRemoveSchema,
supporterVoteIntentionSchema,
type SupporterVoteIntention,
} from '@/lib/schemas/supporter'
import type { CampaignUser, Contact } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import {
requireSupporterRegistrationConsent,
requireSupporterVoteIntentionConsent,
type ConsentDescriptor,
} from '@/utilities/campaignConsent'
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { normalizeBrazilianPhone } from '@/utilities/phone'
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
const MAX_IMPORT_ROWS = 5000
export type SupporterImportRowStatus =
| 'ok'
| 'duplicado_pelo_telefone'
| 'telefone_invalido'
| 'municipio_nao_reconhecido'
| 'nome_invalido'
| 'intencao_invalida'
type SupporterImportPreviewRowBase = {
line: number
nome: string
telefone: string
municipio: string
intencao: string
}
export type SupporterImportOkRow = SupporterImportPreviewRowBase & {
status: 'ok'
normalizedPhone: string
canonicalCity?: string
voteIntention?: SupporterVoteIntention
}
export type SupporterImportPreviewRow =
| SupporterImportOkRow
| (SupporterImportPreviewRowBase & {
status: Exclude<SupporterImportRowStatus, 'ok'>
normalizedPhone?: string
canonicalCity?: string
voteIntention?: SupporterVoteIntention
})
export const isSupporterImportOkRow = (
row: SupporterImportPreviewRow,
): row is SupporterImportOkRow => row.status === 'ok'
export type SupporterImportPreviewResult = {
rows: SupporterImportPreviewRow[]
counts: {
ok: number
duplicate: number
error: number
}
}
const getFreshStaffActor = async (
payload: Payload,
actor: CampaignUser,
req?: PayloadTransactionRequest,
): Promise<CampaignUser> => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (currentActor.role !== 'geral' && currentActor.role !== 'coordenador') {
throw new Error('Somente a coordenação pode gerenciar apoiadores.')
}
return currentActor
}
const getFreshGeneralActor = async (
payload: Payload,
actor: CampaignUser,
req?: PayloadTransactionRequest,
): Promise<CampaignUser> => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (currentActor.role !== 'geral') {
throw new Error('Somente a coordenação geral pode importar apoiadores.')
}
return currentActor
}
const assertNucleusManagement = async (
payload: Payload,
actor: CampaignUser,
nucleusID: number,
req?: PayloadTransactionRequest,
) =>
payload.findByID({
collection: 'electoralNucleus',
id: nucleusID,
depth: 0,
user: actor,
overrideAccess: false,
req,
})
const assertCanManageSupporter = async (
payload: Payload,
actor: CampaignUser,
supporterID: number,
req?: PayloadTransactionRequest,
) =>
payload.findByID({
collection: 'supporter',
id: supporterID,
depth: 0,
user: actor,
overrideAccess: false,
req,
})
const isUniqueSupporterConflict = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error)
return /supporter.*contact.*nucleus|supporter_contact_nucleus|nulls_not_distinct|duplicate key/i.test(
message,
)
}
const upsertContactByPhone = async ({
payload,
req,
phone,
name,
email,
city,
}: {
payload: Payload
req: PayloadTransactionRequest
phone: string
name: string
email?: string
city?: string
}): Promise<{ contactID: number; reused: boolean }> => {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
depth: 0,
limit: 2,
pagination: false,
overrideAccess: true,
req,
})
if (contacts.totalDocs > 1) {
throw new Error(
'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.',
)
}
const existing = contacts.docs[0]
if (existing) {
return { contactID: existing.id, reused: true }
}
const contact = await payload.create({
collection: 'contact',
data: {
name,
phone,
email,
state: 'BA' as Contact['state'],
city,
},
depth: 0,
overrideAccess: true,
req,
})
return { contactID: contact.id, reused: false }
}
const createValidatedSupporter = async (
payload: Payload,
actor: CampaignUser,
input: unknown,
) => {
const data = supporterCreateSchema.parse(input)
try {
return await withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await getFreshStaffActor(payload, actor, req)
if (data.nucleus) {
await assertNucleusManagement(payload, currentActor, data.nucleus, req)
} else if (currentActor.role !== 'geral') {
throw new Error('Somente a coordenação geral pode cadastrar apoiadores sem núcleo.')
}
const registrationConsent = await requireSupporterRegistrationConsent(
payload,
req,
'Consentimento de cadastro de apoiador ainda não configurado.',
)
let voteIntentionConsent: ConsentDescriptor | null = null
if (data.voteIntention) {
voteIntentionConsent = await requireSupporterVoteIntentionConsent(
payload,
req,
'Consentimento de intenção de voto ainda não configurado.',
)
}
if (payload.db.name !== 'postgres') {
throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.')
}
await acquireContactPhoneLocks(payload, req, [data.phone])
const { contactID, reused } = await upsertContactByPhone({
payload,
req,
phone: data.phone,
name: data.name,
email: data.email,
city: data.city,
})
const supporter = await payload.create({
collection: 'supporter',
data: {
contact: contactID,
nucleus: data.nucleus,
voteIntention: data.voteIntention,
source: 'manual',
consent: registrationConsent.id,
consentContentHash: registrationConsent.contentHash,
consentedAt: new Date().toISOString(),
...(voteIntentionConsent && data.voteIntention
? {
voteIntentionConsent: voteIntentionConsent.id,
voteIntentionConsentContentHash: voteIntentionConsent.contentHash,
voteIntentionConsentedAt: new Date().toISOString(),
}
: {}),
createdBy: currentActor.id,
},
depth: 0,
overrideAccess: true,
req,
})
return { ...supporter, contactReused: reused }
},
{ beginFailureMessage: 'Não foi possível iniciar a transação de cadastro do apoiador.' },
)
} catch (error) {
if (isUniqueSupporterConflict(error)) {
throw new Error('Esta pessoa já está cadastrada como apoiador neste núcleo.')
}
throw error
}
}
export const createSupporterRecord = (
payload: Payload,
actor: CampaignUser,
input: unknown,
) => createValidatedSupporter(payload, actor, input)
export const createSupporter = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
return createValidatedSupporter(payload, actor, input)
}
export const setSupporterVoteIntentionRecord = async (
payload: Payload,
actor: CampaignUser,
input: unknown,
) => {
const data = supporterVoteIntentionSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await getFreshStaffActor(payload, actor, req)
await assertCanManageSupporter(payload, currentActor, data.id, req)
const voteIntentionConsent = await requireSupporterVoteIntentionConsent(
payload,
req,
'Consentimento de intenção de voto ainda não configurado.',
)
return payload.update({
collection: 'supporter',
id: data.id,
data: {
voteIntention: data.voteIntention,
voteIntentionConsent: voteIntentionConsent.id,
voteIntentionConsentContentHash: voteIntentionConsent.contentHash,
voteIntentionConsentedAt: new Date().toISOString(),
},
depth: 0,
overrideAccess: true,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a transação de intenção de voto.' },
)
}
export const setSupporterVoteIntention = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
return setSupporterVoteIntentionRecord(payload, actor, input)
}
const normalizeImportHeader = (header: string): string =>
header
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '_')
const mapImportHeader = (header: string): string | null => {
const normalized = normalizeImportHeader(header)
const aliases: Record<string, string> = {
nome: 'nome',
name: 'nome',
telefone: 'telefone',
phone: 'telefone',
celular: 'telefone',
municipio: 'municipio',
cidade: 'municipio',
city: 'municipio',
intencao: 'intencao',
intencao_de_voto: 'intencao',
vote_intention: 'intencao',
}
return aliases[normalized] ?? null
}
const parseVoteIntentionCell = (
value: string,
): SupporterVoteIntention | undefined | 'invalid' => {
const trimmed = value.trim()
if (!trimmed) return undefined
const normalized = normalizeImportHeader(trimmed).replace(/-/g, '_')
const aliases: Record<string, SupporterVoteIntention> = {
certo: 'certo',
tende_a_certo: 'tende_a_certo',
tende: 'tende_a_certo',
indeciso: 'indeciso',
outro: 'outro',
}
return aliases[normalized] ?? 'invalid'
}
export const previewSupporterImportText = async (
payload: Payload,
actor: CampaignUser,
csvText: string,
): Promise<SupporterImportPreviewResult> => {
await getFreshGeneralActor(payload, actor)
await requireSupporterRegistrationConsent(payload)
await requireSupporterVoteIntentionConsent(payload)
let records: Record<string, string>[]
try {
records = parse(csvText, {
columns: (headers: string[]) =>
headers.map((header) => {
const mapped = mapImportHeader(header)
if (!mapped) return `__unknown_${header}`
return mapped
}),
skip_empty_lines: true,
trim: true,
bom: true,
relax_column_count: true,
}) as Record<string, string>[]
} catch {
throw new Error('Não foi possível ler o CSV. Verifique o formato e tente novamente.')
}
if (records.length === 0) {
throw new Error('O CSV está vazio.')
}
if (records.length > MAX_IMPORT_ROWS) {
throw new Error(`O CSV excede o limite de ${MAX_IMPORT_ROWS} linhas.`)
}
const unknownColumns = Object.keys(records[0] ?? {}).filter((key) => key.startsWith('__unknown_'))
if (unknownColumns.length > 0) {
throw new Error(
`Colunas não reconhecidas no CSV: ${unknownColumns
.map((column) => column.replace(/^__unknown_/, ''))
.join(', ')}. Use apenas nome, telefone, municipio e intencao.`,
)
}
const phonesInFile = new Set<string>()
const rows: SupporterImportPreviewRow[] = []
for (const [index, record] of records.entries()) {
const line = index + 2
const nome = String(record.nome ?? '').trim()
const telefoneRaw = String(record.telefone ?? '').trim()
const municipioRaw = String(record.municipio ?? '').trim()
const intencaoRaw = String(record.intencao ?? '').trim()
const base: SupporterImportPreviewRowBase = {
line,
nome,
telefone: telefoneRaw,
municipio: municipioRaw,
intencao: intencaoRaw,
}
if (nome.length < 2) {
rows.push({ ...base, status: 'nome_invalido' })
continue
}
const normalizedPhone = normalizeBrazilianPhone(telefoneRaw)
if (!normalizedPhone) {
rows.push({ ...base, status: 'telefone_invalido' })
continue
}
let canonicalCity: string | undefined
if (municipioRaw) {
const city = resolveBahiaMunicipality(municipioRaw)
if (!city) {
rows.push({ ...base, status: 'municipio_nao_reconhecido', normalizedPhone })
continue
}
canonicalCity = city
}
const voteIntention = parseVoteIntentionCell(intencaoRaw)
if (voteIntention === 'invalid') {
rows.push({ ...base, status: 'intencao_invalida', normalizedPhone, canonicalCity })
continue
}
if (phonesInFile.has(normalizedPhone)) {
rows.push({
...base,
status: 'duplicado_pelo_telefone',
normalizedPhone,
canonicalCity,
voteIntention,
})
continue
}
phonesInFile.add(normalizedPhone)
rows.push({
...base,
status: 'ok',
normalizedPhone,
canonicalCity,
voteIntention,
})
}
// Duplicate against an existing supporter without nucleus (Contact reuse alone is OK).
const candidatePhones = [
...new Set(rows.filter(isSupporterImportOkRow).map((row) => row.normalizedPhone)),
]
if (candidatePhones.length > 0) {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { in: candidatePhones } },
depth: 0,
limit: candidatePhones.length,
pagination: false,
select: { phone: true },
overrideAccess: true,
})
const contactByPhone = new Map(contacts.docs.map((doc) => [doc.phone, doc.id] as const))
const contactIDs = [...contactByPhone.values()]
if (contactIDs.length > 0) {
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,
})
const supporterContactIDs = new Set(
existingSupporters.docs
.map((doc) => relationshipId(doc.contact))
.filter((id): id is number => id !== null),
)
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index]
if (!row || !isSupporterImportOkRow(row)) continue
const contactID = contactByPhone.get(row.normalizedPhone)
if (contactID && supporterContactIDs.has(contactID)) {
rows[index] = { ...row, status: 'duplicado_pelo_telefone' }
}
}
}
}
const counts = {
ok: rows.filter(isSupporterImportOkRow).length,
duplicate: rows.filter((row) => row.status === 'duplicado_pelo_telefone').length,
error: rows.filter(
(row) => row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone',
).length,
}
return { rows, counts }
}
export const previewSupporterImport = async (csvText: string) => {
const { payload, actor } = await getCampaignActionContext()
return previewSupporterImportText(payload, actor, csvText)
}
export const confirmSupporterImportRecord = async (
payload: Payload,
actor: CampaignUser,
input: unknown,
) => {
const data = supporterImportConfirmSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await getFreshGeneralActor(payload, actor, req)
const registrationConsent = await requireSupporterRegistrationConsent(payload, req)
const voteIntentionConsent = await requireSupporterVoteIntentionConsent(payload, req)
if (payload.db.name !== 'postgres') {
throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.')
}
const phones = [...new Set(data.rows.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
}
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 }
},
{ beginFailureMessage: 'Não foi possível iniciar a transação de importação de apoiadores.' },
)
}
export const confirmSupporterImport = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
return confirmSupporterImportRecord(payload, actor, input)
}
const contactHasOtherJoins = async (
payload: Payload,
contactID: number,
excludeSupporterID: number,
req?: PayloadTransactionRequest,
): Promise<boolean> => {
const [leaderships, signatures, subscriptions, supporters] = await Promise.all([
payload.find({
collection: 'leadership',
where: { contact: { equals: contactID } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
}),
payload.find({
collection: 'signature',
where: { contact: { equals: contactID } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
}),
payload.find({
collection: 'subscription',
where: { contact: { equals: contactID } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
}),
payload.find({
collection: 'supporter',
where: {
and: [{ contact: { equals: contactID } }, { id: { not_equals: excludeSupporterID } }],
},
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
}),
])
return (
leaderships.totalDocs > 0 ||
signatures.totalDocs > 0 ||
subscriptions.totalDocs > 0 ||
supporters.totalDocs > 0
)
}
const anonymizeContact = async (
payload: Payload,
contactID: number,
req: PayloadTransactionRequest,
) => {
// Must match Contact phone validation: DDD + 9 + 8 digits (`^[1-9]{2}9\d{8}$`).
const tombstonePhone = `999${String(contactID).padStart(8, '0')}`
await acquireContactPhoneLocks(payload, req, [tombstonePhone])
await payload.update({
collection: 'contact',
id: contactID,
data: {
name: 'Titular removido',
email: null,
phone: tombstonePhone,
gender: null,
city: null,
postalCode: null,
},
depth: 0,
overrideAccess: true,
req,
})
}
export const removeSupporterDataRecord = async (
payload: Payload,
actor: CampaignUser,
input: unknown,
) => {
const data = supporterRemoveSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await getFreshStaffActor(payload, actor, req)
const supporter = await assertCanManageSupporter(payload, currentActor, data.id, req)
const contactID = requireRelationshipId(supporter.contact)
await payload.delete({
collection: 'supporter',
id: data.id,
overrideAccess: true,
req,
})
const hasOtherJoins = await contactHasOtherJoins(payload, contactID, data.id, req)
if (!hasOtherJoins) {
await anonymizeContact(payload, contactID, req)
}
return { removed: true, contactAnonymized: !hasOtherJoins }
},
{ beginFailureMessage: 'Não foi possível iniciar a transação de remoção de dados.' },
)
}
export const removeSupporterData = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
return removeSupporterDataRecord(payload, actor, input)
}

View file

@ -0,0 +1,228 @@
import type { CollectionConfig } from 'payload'
import { APIError } from 'payload'
import {
canCreateSupporter,
canDeleteSupporter,
canManageLeadershipInternal,
canManageSupporter,
canReadLeadershipInternal,
canReadSupporter,
canSetAdministrativeLeadershipField,
} from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship'
export const Supporter: CollectionConfig = {
slug: 'supporter',
labels: {
singular: 'Apoiador',
plural: 'Apoiadores',
},
admin: {
group: 'Campanha',
useAsTitle: 'contact',
defaultColumns: ['contact', 'nucleus', 'voteIntention', 'source', 'updatedAt'],
},
access: {
create: canCreateSupporter,
read: canReadSupporter,
update: canManageSupporter,
delete: canDeleteSupporter,
},
hooks: {
beforeChange: [
async ({ data, operation, originalDoc, req }) => {
if (operation === 'create' && req.user?.collection === 'campaignUser') {
data.createdBy = req.user.id
}
const contactID = relationshipId(data.contact ?? originalDoc?.contact)
const nucleusID = relationshipId(data.nucleus ?? originalDoc?.nucleus)
if (!contactID || !nucleusID) return data
const existingLeadership = await req.payload.find({
collection: 'leadership',
where: {
and: [{ contact: { equals: contactID } }, { nucleus: { equals: nucleusID } }],
},
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
})
if (existingLeadership.totalDocs > 0) {
throw new APIError(
'Este contato já é liderança neste núcleo e não pode ser cadastrado como apoiador.',
409,
)
}
return data
},
],
},
fields: [
{
name: 'contact',
type: 'relationship',
relationTo: 'contact',
label: 'Contato',
required: true,
index: true,
access: {
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'nucleus',
type: 'relationship',
relationTo: 'electoralNucleus',
label: 'Núcleo eleitoral',
index: true,
access: {
update: canManageLeadershipInternal,
},
},
{
name: 'voteIntention',
type: 'select',
label: 'Intenção de voto',
index: true,
access: {
create: canManageLeadershipInternal,
read: canReadLeadershipInternal,
update: canManageLeadershipInternal,
},
options: [
{ label: 'Certo', value: 'certo' },
{ label: 'Tende a certo', value: 'tende_a_certo' },
{ label: 'Indeciso', value: 'indeciso' },
{ label: 'Outro', value: 'outro' },
],
},
{
name: 'consent',
type: 'relationship',
relationTo: 'consent',
label: 'Consentimento de cadastro',
access: {
create: canSetAdministrativeLeadershipField,
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'consentContentHash',
type: 'text',
label: 'Hash do consentimento de cadastro',
admin: {
readOnly: true,
},
access: {
create: canSetAdministrativeLeadershipField,
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'consentedAt',
type: 'date',
label: 'Consentimento de cadastro em',
admin: {
readOnly: true,
},
access: {
create: canSetAdministrativeLeadershipField,
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'voteIntentionConsent',
type: 'relationship',
relationTo: 'consent',
label: 'Consentimento de intenção de voto',
access: {
create: canSetAdministrativeLeadershipField,
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'voteIntentionConsentContentHash',
type: 'text',
label: 'Hash do consentimento de intenção',
admin: {
readOnly: true,
},
access: {
create: canSetAdministrativeLeadershipField,
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'voteIntentionConsentedAt',
type: 'date',
label: 'Consentimento de intenção em',
admin: {
readOnly: true,
},
access: {
create: canSetAdministrativeLeadershipField,
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'source',
type: 'select',
label: 'Origem',
required: true,
defaultValue: 'manual',
index: true,
options: [
{ label: 'Importação CSV', value: 'import_csv' },
{ label: 'Cadastro manual', value: 'manual' },
{ label: 'Convite', value: 'convite' },
{ label: 'Evento', value: 'evento' },
],
access: {
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'consentNote',
type: 'textarea',
label: 'Registro de consentimento externo',
maxLength: 2000,
access: {
create: canManageLeadershipInternal,
read: canReadLeadershipInternal,
update: canManageLeadershipInternal,
},
},
{
name: 'notes',
type: 'textarea',
label: 'Observações internas',
maxLength: 3000,
access: {
create: canManageLeadershipInternal,
read: canReadLeadershipInternal,
update: canManageLeadershipInternal,
},
},
{
name: 'createdBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Criado por',
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetAdministrativeLeadershipField,
read: canReadLeadershipInternal,
update: canSetAdministrativeLeadershipField,
},
},
],
}

View file

@ -9,14 +9,20 @@ import type { CampaignUser } from '@/payload-types'
export const CampaignBottomNav = ({ role }: { role: CampaignUser['role'] }) => {
const pathname = usePathname()
const navItems = getCampaignNav(role)
return (
<nav
aria-label="Navegação principal"
className="fixed inset-x-0 bottom-0 border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden"
>
<ul className="m-0 grid grid-cols-2 list-none p-0">
{getCampaignNav(role).map((item) => {
<ul
className={cn(
'm-0 grid list-none p-0',
navItems.length >= 3 ? 'grid-cols-3' : 'grid-cols-2',
)}
>
{navItems.map((item) => {
const active = isCampaignNavActive(pathname, item.href)
return (

View file

@ -0,0 +1,52 @@
'use client'
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 { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
export const RemoveSupporterDataButton = ({ supporterId }: { supporterId: number }) => {
const [state, formAction, pending] = useActionState<
SupporterRemoveFormState,
FormData
>(removeSupporterDataFormAction, {})
return (
<section className="rounded-[6px] border border-destructive/30 bg-destructive/5 p-4">
<h2 className="font-medium text-destructive">Remover meus dados</h2>
<p className="mt-1 text-sm text-muted-foreground">
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.
</p>
{state.message ? (
<Alert variant="destructive" className="mt-4">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<form action={formAction} className="mt-4">
<input type="hidden" name="id" value={supporterId} />
<Button
type="submit"
variant="destructive"
className="min-h-11"
disabled={pending}
onClick={(event) => {
if (
!window.confirm(
'Confirma a remoção dos dados deste apoiador? Esta ação não pode ser desfeita.',
)
) {
event.preventDefault()
}
}}
>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Removendo…' : 'Remover dados'}
</Button>
</form>
</section>
)
}

View file

@ -0,0 +1,229 @@
'use client'
import { type FormEvent, useRef, useState, useTransition } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
import { CampaignSearchInput } from '@/components/campaign/CampaignSearchInput'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { cn } from '@/lib/utils'
import type { SupporterNucleusOption } from '@/utilities/supporterViewModels'
import {
buildSupporterListSearchParams,
supporterVoteIntentionLabels,
type SupporterListState,
} from '@/utilities/supporterUi'
import { municipalityComboboxOptions } from '@/utilities/territoryComboboxOptions'
type FilterValues = {
q: string
voteIntention: string
city: string
nucleus: string
}
const valuesFromState = (state: SupporterListState): FilterValues => ({
q: state.q ?? '',
voteIntention: state.voteIntention ?? '',
city: state.city ?? '',
nucleus: state.nucleus ? String(state.nucleus) : '',
})
const filterNames = ['voteIntention', 'city', 'nucleus'] as const
type FilterFieldsProps = {
values: FilterValues
nucleusOptions: SupporterNucleusOption[]
updateFilter: (name: (typeof filterNames)[number], value: string) => void
updateCity: (city: string) => void
}
const FilterFields = ({
values,
nucleusOptions,
updateFilter,
updateCity,
}: FilterFieldsProps) => (
<form
autoComplete="off"
onSubmit={(event) => event.preventDefault()}
className="grid gap-4 lg:grid-cols-3"
>
<Field>
<FieldLabel htmlFor="supporter-voteIntention">Intenção de voto</FieldLabel>
<NativeSelect
id="supporter-voteIntention"
name="voteIntention"
value={values.voteIntention}
onChange={(event) => updateFilter('voteIntention', event.target.value)}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Todas</NativeSelectOption>
{Object.entries(supporterVoteIntentionLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="supporter-city">Município</FieldLabel>
<StrictCombobox
id="supporter-city"
options={municipalityComboboxOptions()}
value={values.city}
onValueChange={updateCity}
/>
</Field>
<Field>
<FieldLabel htmlFor="supporter-nucleus">Núcleo</FieldLabel>
<NativeSelect
id="supporter-nucleus"
name="nucleus"
value={values.nucleus}
onChange={(event) => updateFilter('nucleus', event.target.value)}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Todos</NativeSelectOption>
{nucleusOptions.map((nucleus) => (
<NativeSelectOption key={nucleus.id} value={String(nucleus.id)}>
{nucleus.name}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</form>
)
export const SupporterFilters = ({
state,
nucleusOptions,
}: {
state: SupporterListState
nucleusOptions: SupporterNucleusOption[]
}) => {
const router = useRouter()
const pathname = usePathname()
const initialValues = valuesFromState(state)
const valuesRef = useRef(initialValues)
const [values, setValues] = useState(initialValues)
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const replaceValues = (nextValues: FilterValues) => {
valuesRef.current = nextValues
setValues(nextValues)
const params = buildSupporterListSearchParams({
page: 1,
q: nextValues.q,
voteIntention: nextValues.voteIntention as SupporterListState['voteIntention'],
city: nextValues.city,
nucleus: nextValues.nucleus ? Number(nextValues.nucleus) : undefined,
})
const query = params.toString()
startTransition(() => {
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
})
}
const updateFilter = (name: (typeof filterNames)[number], value: string) => {
replaceValues({ ...valuesRef.current, [name]: value })
}
const updateCity = (city: string) => {
replaceValues({ ...valuesRef.current, city })
}
const clearFilters = () => {
const cleared = Object.fromEntries(filterNames.map((name) => [name, ''])) as Partial<FilterValues>
replaceValues({ ...valuesRef.current, ...cleared })
}
const submitSearch = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
replaceValues({ ...valuesRef.current, q: valuesRef.current.q.trim() })
}
const hasFilters = filterNames.some((name) => Boolean(values[name]))
return (
<div
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending}
aria-busy={isPending}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
<div>
<form onSubmit={submitSearch} className="flex gap-2">
<CampaignSearchInput
id="supporter-search"
label="Buscar por nome, telefone ou município"
name="q"
value={values.q}
onChange={(event) => {
const nextValues = { ...valuesRef.current, q: event.target.value }
valuesRef.current = nextValues
setValues(nextValues)
}}
placeholder="Buscar por nome, telefone ou município"
enterKeyHint="search"
/>
<Button type="submit" className="min-h-11 rounded-[6px]" disabled={isPending}>
Buscar
</Button>
</form>
</div>
<div className="rounded-[6px] border bg-card">
<Button
type="button"
variant="ghost"
className="min-h-11 w-full justify-start rounded-[6px] px-3 lg:hidden"
aria-expanded={mobileFiltersOpen}
aria-controls="supporter-filter-controls"
onClick={() => setMobileFiltersOpen((open) => !open)}
>
<FilterIcon data-icon="inline-start" aria-hidden="true" />
<span>Filtros</span>
<ChevronDownIcon
data-icon="inline-end"
className="ml-auto transition-transform group-aria-expanded/button:rotate-180"
aria-hidden="true"
/>
</Button>
<div
id="supporter-filter-controls"
className={cn(
mobileFiltersOpen ? 'block' : 'hidden',
'border-t p-4 lg:block lg:border-t-0',
)}
>
<FilterFields
values={values}
nucleusOptions={nucleusOptions}
updateFilter={updateFilter}
updateCity={updateCity}
/>
{hasFilters ? (
<div className="mt-4 flex justify-end">
<Button
type="button"
variant="ghost"
className="min-h-11 rounded-[6px]"
onClick={clearFilters}
>
<XIcon data-icon="inline-start" aria-hidden="true" />
Limpar filtros
</Button>
</div>
) : null}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,310 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import type { SupporterFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
import { FormattedInput } from '@/components/FormattedInput'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
import type { SupporterNucleusOption } from '@/utilities/supporterViewModels'
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
export type SupporterFormAction = (
state: SupporterFormState,
formData: FormData,
) => Promise<SupporterFormState>
const fieldError = (
fieldErrors: Record<string, string[]> | undefined,
field: string,
): string | undefined => fieldErrors?.[field]?.[0]
const errorProps = (fieldErrors: Record<string, string[]> | undefined, field: string) => {
const error = fieldError(fieldErrors, field)
return {
error,
invalid: Boolean(error),
describedBy: error ? `supporter-${field}-error` : undefined,
}
}
const ConsentBlock = ({
id,
name,
title,
description,
configured,
error,
required = true,
}: {
id: string
name: string
title: string
description: string
configured: boolean
error?: string
required?: boolean
}) => (
<section className="rounded-[6px] border bg-muted/30 p-4" aria-labelledby={`${id}-title`}>
<h2 id={`${id}-title`} className="font-medium">
{title}
</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
{!configured ? (
<Alert variant="destructive" className="mt-3">
<AlertTitle>Consentimento não configurado</AlertTitle>
<AlertDescription>
O texto ainda não foi cadastrado no admin. Não é possível concluir o cadastro.
</AlertDescription>
</Alert>
) : (
<Field orientation="horizontal" className="mt-3" data-invalid={Boolean(error)}>
<Checkbox
id={id}
name={name}
value="true"
required={required}
aria-invalid={Boolean(error)}
aria-describedby={error ? `${id}-error` : `${id}-help`}
/>
<FieldContent>
<FieldLabel htmlFor={id}>Li e confirmo o consentimento do titular *</FieldLabel>
<FieldDescription id={`${id}-help`}>
O operador atesta que o apoiador concordou com o uso dos dados conforme o texto
versionado no admin.
</FieldDescription>
{error ? <FieldError id={`${id}-error`}>{error}</FieldError> : null}
</FieldContent>
</Field>
)}
</section>
)
export const SupporterForm = ({
action,
nucleusOptions,
registrationConsentConfigured,
voteIntentionConsentConfigured,
requireNucleus,
}: {
action: SupporterFormAction
nucleusOptions: SupporterNucleusOption[]
registrationConsentConfigured: boolean
voteIntentionConsentConfigured: boolean
requireNucleus: boolean
}) => {
const router = useRouter()
const [state, formAction, pending] = useActionState(action, {})
const [voteIntention, setVoteIntention] = useState<SupporterVoteIntention | ''>(
(state.values?.voteIntention as SupporterVoteIntention | undefined) ?? '',
)
const values = state.values
useEffect(() => {
if (state.status !== 'success') return
toast.success(state.message)
if (state.supporterId) {
router.push(`/campanha/apoiadores/${state.supporterId}`, { scroll: false })
}
}, [router, state.message, state.status, state.supporterId])
const nameField = errorProps(state.fieldErrors, 'name')
const phoneField = errorProps(state.fieldErrors, 'phone')
const emailField = errorProps(state.fieldErrors, 'email')
const cityField = errorProps(state.fieldErrors, 'city')
const nucleusField = errorProps(state.fieldErrors, 'nucleus')
const voteIntentionField = errorProps(state.fieldErrors, 'voteIntention')
return (
<form action={formAction} className="flex flex-col gap-6">
{(state.message || state.fieldErrors?.form) && state.status !== 'success' ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível cadastrar</AlertTitle>
<AlertDescription>
{state.message ? <p>{state.message}</p> : null}
{state.fieldErrors?.form?.map((error) => (
<p key={error}>{error}</p>
))}
</AlertDescription>
</Alert>
) : null}
<FieldGroup>
<Field data-invalid={nameField.invalid}>
<FieldLabel htmlFor="supporter-name">Nome *</FieldLabel>
<Input
id="supporter-name"
name="name"
required
minLength={2}
defaultValue={values?.name}
autoComplete="name"
aria-invalid={nameField.invalid}
aria-describedby={nameField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{nameField.error ? (
<FieldError id="supporter-name-error">{nameField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={phoneField.invalid}>
<FieldLabel htmlFor="supporter-phone">Celular *</FieldLabel>
<FormattedInput
id="supporter-phone"
name="phone"
required
inputMode="tel"
autoComplete="tel"
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
format={formatBrazilianPhoneInput}
sanitize={sanitizeBrazilianPhoneInput}
aria-invalid={phoneField.invalid}
aria-describedby={phoneField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{phoneField.error ? (
<FieldError id="supporter-phone-error">{phoneField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={emailField.invalid}>
<FieldLabel htmlFor="supporter-email">E-mail</FieldLabel>
<Input
id="supporter-email"
name="email"
type="email"
autoComplete="email"
defaultValue={values?.email}
aria-invalid={emailField.invalid}
aria-describedby={emailField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{emailField.error ? (
<FieldError id="supporter-email-error">{emailField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={cityField.invalid}>
<FieldLabel htmlFor="supporter-city">Município</FieldLabel>
<Input
id="supporter-city"
name="city"
defaultValue={values?.city}
aria-invalid={cityField.invalid}
aria-describedby={cityField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{cityField.error ? (
<FieldError id="supporter-city-error">{cityField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={nucleusField.invalid}>
<FieldLabel htmlFor="supporter-nucleus">
Núcleo{requireNucleus ? ' *' : ''}
</FieldLabel>
<NativeSelect
id="supporter-nucleus"
name="nucleus"
required={requireNucleus}
defaultValue={values?.nucleus ?? ''}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
{!requireNucleus ? <NativeSelectOption value="">Sem núcleo</NativeSelectOption> : null}
{nucleusOptions.map((nucleus) => (
<NativeSelectOption key={nucleus.id} value={String(nucleus.id)}>
{nucleus.name}
</NativeSelectOption>
))}
</NativeSelect>
{nucleusField.error ? (
<FieldError id="supporter-nucleus-error">{nucleusField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={voteIntentionField.invalid}>
<FieldLabel>Intenção de voto (opcional)</FieldLabel>
<input type="hidden" name="voteIntention" value={voteIntention} />
<ToggleGroup
type="single"
value={voteIntention}
onValueChange={(value) => setVoteIntention((value as SupporterVoteIntention) ?? '')}
variant="outline"
className="flex w-full flex-wrap"
>
{Object.entries(supporterVoteIntentionLabels).map(([value, label]) => (
<ToggleGroupItem key={value} value={value} className="min-h-11 flex-1">
{label}
</ToggleGroupItem>
))}
</ToggleGroup>
{voteIntentionField.error ? (
<FieldError id="supporter-voteIntention-error">{voteIntentionField.error}</FieldError>
) : null}
</Field>
</FieldGroup>
<ConsentBlock
id="supporter-registration-consent"
name="consentAccepted"
title="Consentimento de cadastro"
description="Confirme que o titular autorizou o cadastro como apoiador."
configured={registrationConsentConfigured}
error={fieldError(state.fieldErrors, 'consentAccepted')}
/>
{voteIntention ? (
<ConsentBlock
id="supporter-vote-intention-consent"
name="voteIntentionConsentAccepted"
title="Consentimento de intenção de voto"
description="Dado sensível — exige consentimento destacado separado."
configured={voteIntentionConsentConfigured}
error={fieldError(state.fieldErrors, 'voteIntentionConsentAccepted')}
/>
) : null}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={() => router.push('/campanha/apoiadores', { scroll: false })}
disabled={pending}
>
Cancelar
</Button>
<Button
type="submit"
className="min-h-11"
disabled={
pending ||
!registrationConsentConfigured ||
(Boolean(voteIntention) && !voteIntentionConsentConfigured)
}
>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Cadastrando…' : 'Cadastrar apoiador'}
</Button>
</div>
</form>
)
}

View file

@ -0,0 +1,306 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState, useTransition } from 'react'
import { CheckCircle2Icon, FileUpIcon, UploadIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
confirmSupporterImport,
isSupporterImportOkRow,
previewSupporterImport,
type SupporterImportPreviewResult,
type SupporterImportPreviewRow,
} from '@/app/(campaign)/campanha/actions/supporter'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { Textarea } from '@/components/ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { cn } from '@/lib/utils'
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
const importStatusLabels: Record<SupporterImportPreviewRow['status'], string> = {
ok: 'Pronto',
duplicado_pelo_telefone: 'Duplicado (telefone)',
telefone_invalido: 'Telefone inválido',
municipio_nao_reconhecido: 'Município não reconhecido',
nome_invalido: 'Nome inválido',
intencao_invalida: 'Intenção inválida',
}
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 url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'relatorio-erros-apoiadores.csv'
link.click()
URL.revokeObjectURL(url)
}
export const SupporterImportWizard = () => {
const router = useRouter()
const [step, setStep] = useState(0)
const [preview, setPreview] = useState<SupporterImportPreviewResult | null>(null)
const [previewFilter, setPreviewFilter] = useState<'all' | 'errors'>('all')
const [operatorAttested, setOperatorAttested] = useState(false)
const [consentNote, setConsentNote] = useState('')
const [error, setError] = useState<string>()
const [isPending, startTransition] = useTransition()
const [confirmResult, setConfirmResult] = useState<{
created: number
skipped: number
} | null>(null)
const handleFile = (file: File | null) => {
if (!file) return
setError(undefined)
startTransition(async () => {
try {
const csvText = await file.text()
const result = await previewSupporterImport(csvText)
setPreview(result)
setStep(1)
} catch (previewError) {
setError(
previewError instanceof Error
? previewError.message
: 'Não foi possível ler o arquivo.',
)
}
})
}
const visibleRows =
preview?.rows.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,
})
setConfirmResult({ created: result.created, skipped: result.skipped })
setStep(2)
toast.success('Importação concluída.')
router.refresh()
} catch (confirmError) {
setError(
confirmError instanceof Error
? confirmError.message
: 'Não foi possível confirmar a importação.',
)
}
})
}
return (
<div className="flex flex-col gap-6">
<ol className="flex flex-wrap items-center gap-2 text-sm">
{steps.map((label, index) => (
<li key={label} className="flex items-center gap-2">
<span
className={cn(
'inline-flex size-7 items-center justify-center rounded-full border text-xs font-semibold',
index <= step ? 'border-primary bg-primary text-primary-foreground' : 'text-muted-foreground',
)}
>
{index < step ? <CheckCircle2Icon className="size-4" aria-hidden="true" /> : index + 1}
</span>
<span className={index <= step ? 'font-medium' : 'text-muted-foreground'}>{label}</span>
{index < steps.length - 1 ? <span className="text-muted-foreground">→</span> : null}
</li>
))}
</ol>
{error ? (
<Alert variant="destructive">
<AlertTitle>Não foi possível continuar</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{step === 0 ? (
<section className="rounded-[6px] border border-dashed bg-card p-8 text-center">
<FileUpIcon className="mx-auto size-10 text-muted-foreground" aria-hidden="true" />
<h2 className="mt-4 text-lg font-semibold">Enviar planilha CSV</h2>
<p className="mt-2 text-sm text-muted-foreground">
Colunas permitidas: nome, telefone, municipio, intencao.
</p>
<div className="mt-6 flex flex-col items-center gap-3">
<Input
type="file"
accept=".csv,text/csv"
className="max-w-sm"
disabled={isPending}
onChange={(event) => handleFile(event.target.files?.[0] ?? null)}
/>
{isPending ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-4" /> Processando arquivo…
</p>
) : null}
</div>
</section>
) : null}
{step === 1 && preview ? (
<section className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
<Badge variant="estimate-confirmed">{preview.counts.ok} prontos</Badge>
<Badge variant="secondary">{preview.counts.duplicate} duplicados (telefone)</Badge>
<Badge variant="destructive">{preview.counts.error} erros</Badge>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<ToggleGroup
type="single"
value={previewFilter}
onValueChange={(value) => setPreviewFilter((value as 'all' | 'errors') ?? 'all')}
variant="outline"
>
<ToggleGroupItem value="all">Todos</ToggleGroupItem>
<ToggleGroupItem value="errors">Só erros</ToggleGroupItem>
</ToggleGroup>
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={() => downloadErrorReport(preview.rows)}
>
Baixar relatório de erros
</Button>
</div>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Linha</TableHead>
<TableHead>Nome</TableHead>
<TableHead>Telefone</TableHead>
<TableHead>Município</TableHead>
<TableHead>Intenção</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.slice(0, 100).map((row) => (
<TableRow key={row.line}>
<TableCell>{row.line}</TableCell>
<TableCell>{row.nome}</TableCell>
<TableCell>{row.telefone}</TableCell>
<TableCell>{row.municipio || '—'}</TableCell>
<TableCell>
{row.voteIntention
? supporterVoteIntentionLabels[row.voteIntention]
: '—'}
</TableCell>
<TableCell>{importStatusLabels[row.status]}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Field orientation="horizontal">
<Checkbox
id="operator-attested"
checked={operatorAttested}
onCheckedChange={(checked) => setOperatorAttested(checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor="operator-attested">
Atesto que os apoiadores importados consentiram com o cadastro *
</FieldLabel>
<FieldDescription>
O operador confirma a base legal e o consentimento dos titulares.
</FieldDescription>
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="consent-note">Observação de consentimento (opcional)</FieldLabel>
<Textarea
id="consent-note"
value={consentNote}
onChange={(event) => setConsentNote(event.target.value)}
className="min-h-20 rounded-[6px]"
/>
</Field>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-between">
<Button type="button" variant="outline" className="min-h-11" onClick={() => setStep(0)}>
Voltar
</Button>
<Button
type="button"
className="min-h-11"
disabled={!operatorAttested || preview.counts.ok === 0 || isPending}
onClick={confirmImport}
>
{isPending ? <Spinner data-icon="inline-start" /> : <UploadIcon data-icon="inline-start" />}
{isPending ? 'Importando…' : `Importar ${preview.counts.ok} apoiadores`}
</Button>
</div>
</section>
) : null}
{step === 2 && confirmResult ? (
<section className="rounded-[6px] border bg-card p-6 text-center">
<CheckCircle2Icon className="mx-auto size-10 text-estimate-confirmed-foreground" />
<h2 className="mt-4 text-lg font-semibold">Importação concluída</h2>
<p className="mt-2 text-muted-foreground">
{confirmResult.created} criados · {confirmResult.skipped} ignorados
</p>
<Button asChild className="mt-6 min-h-11">
<Link href="/campanha/apoiadores">Ver apoiadores</Link>
</Button>
</section>
) : null}
</div>
)
}

View file

@ -0,0 +1,155 @@
import Link from 'next/link'
import { PhoneIcon } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { formatBrazilianPhoneInput } from '@/utilities/phone'
import type { SupporterListItemViewModel } from '@/utilities/supporterViewModels'
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
export type SupporterListProps = {
supporters: SupporterListItemViewModel[]
}
const voteIntentionBadgeVariant = (
voteIntention: SupporterListItemViewModel['voteIntention'],
): 'estimate-confirmed' | 'estimate-pending' | 'secondary' | 'outline' => {
if (voteIntention === 'certo' || voteIntention === 'tende_a_certo') return 'estimate-confirmed'
if (voteIntention === 'indeciso') return 'estimate-pending'
if (voteIntention === 'outro') return 'secondary'
return 'outline'
}
const SupporterCard = ({ supporter }: { supporter: SupporterListItemViewModel }) => {
const voteIntentionLabel = supporter.voteIntention
? supporterVoteIntentionLabels[supporter.voteIntention]
: null
return (
<Card>
<CardHeader className="gap-2">
<div className="flex items-start justify-between gap-3">
<CardTitle className="text-base">
<Link
href={`/campanha/apoiadores/${supporter.id}`}
className="text-primary underline-offset-4 hover:underline"
>
{supporter.name}
</Link>
</CardTitle>
{voteIntentionLabel ? (
<Badge variant={voteIntentionBadgeVariant(supporter.voteIntention)}>
{voteIntentionLabel}
</Badge>
) : (
<Badge variant="outline">Sem intenção</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{supporter.city ?? 'Município não informado'}
</p>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm">
{supporter.nucleusName ? (
<Link
href={`/campanha/nucleos/${supporter.nucleusSlug}`}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{supporter.nucleusName}
</Link>
) : (
<span className="text-muted-foreground">Sem núcleo vinculado</span>
)}
</p>
{supporter.phone ? (
<p className="flex items-center gap-2 text-sm tabular-nums">
<PhoneIcon className="size-4 text-muted-foreground" aria-hidden="true" />
{formatBrazilianPhoneInput(supporter.phone)}
</p>
) : null}
<Button asChild variant="outline" className="min-h-11 w-full">
<Link href={`/campanha/apoiadores/${supporter.id}`}>Abrir ficha</Link>
</Button>
</CardContent>
</Card>
)
}
export const SupporterList = ({ supporters }: SupporterListProps) => (
<>
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
{supporters.map((supporter) => (
<SupporterCard key={supporter.id} supporter={supporter} />
))}
</div>
<div data-view="desktop-table" className="hidden overflow-hidden rounded-xl border md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Município</TableHead>
<TableHead>Núcleo</TableHead>
<TableHead>Intenção</TableHead>
<TableHead>Telefone</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{supporters.map((supporter) => {
const voteIntentionLabel = supporter.voteIntention
? supporterVoteIntentionLabels[supporter.voteIntention]
: null
return (
<TableRow key={supporter.id}>
<TableCell className="max-w-52 whitespace-normal">
<Link
href={`/campanha/apoiadores/${supporter.id}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{supporter.name}
</Link>
</TableCell>
<TableCell>{supporter.city ?? '—'}</TableCell>
<TableCell className="max-w-48 whitespace-normal">
{supporter.nucleusName ? (
<Link
href={`/campanha/nucleos/${supporter.nucleusSlug}`}
className="text-primary underline-offset-4 hover:underline"
>
{supporter.nucleusName}
</Link>
) : (
<span className="text-muted-foreground">Sem núcleo vinculado</span>
)}
</TableCell>
<TableCell>
{voteIntentionLabel ? (
<Badge variant={voteIntentionBadgeVariant(supporter.voteIntention)}>
{voteIntentionLabel}
</Badge>
) : (
<Badge variant="outline">Sem intenção</Badge>
)}
</TableCell>
<TableCell className="tabular-nums">
{supporter.phone ? formatBrazilianPhoneInput(supporter.phone) : '—'}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</>
)

View file

@ -0,0 +1,31 @@
import type { SupporterListOverviewViewModel } from '@/utilities/supporterViewModels'
const numberFormatter = new Intl.NumberFormat('pt-BR')
export const SupporterListOverview = ({
view,
}: {
view: SupporterListOverviewViewModel
}) => (
<section aria-labelledby="supporter-list-overview" className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<h2 id="supporter-list-overview" className="sr-only">
Indicadores dos apoiadores filtrados
</h2>
<div className="rounded-[6px] border bg-card p-4 text-center">
<p className="text-2xl font-bold tabular-nums">{numberFormatter.format(view.total)}</p>
<p className="text-sm text-muted-foreground">Total</p>
</div>
<div className="rounded-[6px] border bg-card p-4 text-center">
<p className="text-2xl font-bold tabular-nums text-estimate-confirmed-foreground">
{numberFormatter.format(view.certoAndTende)}
</p>
<p className="text-sm text-muted-foreground">Certo + Tende</p>
</div>
<div className="rounded-[6px] border bg-card p-4 text-center">
<p className="text-2xl font-bold tabular-nums text-estimate-pending-foreground">
{numberFormatter.format(view.indeciso)}
</p>
<p className="text-sm text-muted-foreground">Indecisos</p>
</div>
</section>
)

View file

@ -0,0 +1,61 @@
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 (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={buildSupporterListHref(state, Math.max(1, state.page - 1))}
aria-disabled={state.page <= 1}
className={state.page <= 1 ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
{visiblePages.map((page, index) => {
const previousPage = visiblePages[index - 1]
const hasGap = previousPage !== undefined && page - previousPage > 1
return (
<PaginationItem key={page} className="contents">
{hasGap ? <PaginationEllipsis /> : null}
<PaginationLink
href={buildSupporterListHref(state, page)}
isActive={page === state.page}
aria-label={`Ir para a página ${page}`}
>
{page}
</PaginationLink>
</PaginationItem>
)
})}
<PaginationItem>
<PaginationNext
href={buildSupporterListHref(state, Math.min(totalPages, state.page + 1))}
aria-disabled={state.page >= totalPages}
className={state.page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}

View file

@ -0,0 +1,81 @@
'use client'
import { useState } from 'react'
import { CopyIcon, MessageCircleIcon } from 'lucide-react'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { buildWhatsAppUrl } from '@/utilities/phone'
export const SupporterShareKit = ({
supporterName,
siteUrl,
phone,
}: {
supporterName: string
siteUrl: string
phone: string
}) => {
const [copied, setCopied] = useState(false)
const message = `Olá! ${supporterName}, conheça a campanha do deputado Jorge Solla: ${siteUrl}`
const copyMessage = async () => {
try {
await navigator.clipboard.writeText(message)
setCopied(true)
toast.success('Texto copiado.')
window.setTimeout(() => setCopied(false), 2000)
} catch {
toast.error('Não foi possível copiar o texto.')
}
}
let whatsAppHref: string | null = null
try {
whatsAppHref = buildWhatsAppUrl(phone, message)
} catch {
whatsAppHref = null
}
return (
<section className="rounded-[6px] border bg-card p-4" aria-labelledby="share-kit-title">
<h2 id="share-kit-title" className="font-medium">
Kit de compartilhamento
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Compartilhe individualmente — não use para disparo em massa (vedado pelo TSE).
</p>
<Alert className="mt-4">
<AlertDescription>
Envie apenas para contatos que autorizaram contato. A campanha não realiza disparos
automatizados.
</AlertDescription>
</Alert>
<Textarea
readOnly
value={message}
className="mt-4 min-h-24 rounded-[6px]"
aria-label="Mensagem template para compartilhamento"
/>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
{whatsAppHref ? (
<Button asChild className="min-h-11">
<a href={whatsAppHref} target="_blank" rel="noopener noreferrer">
<MessageCircleIcon data-icon="inline-start" aria-hidden="true" />
Enviar no WhatsApp
</a>
</Button>
) : null}
<Button type="button" variant="outline" className="min-h-11" onClick={copyMessage}>
<CopyIcon data-icon="inline-start" aria-hidden="true" />
{copied ? 'Copiado' : 'Copiar texto'}
</Button>
</div>
</section>
)
}

View file

@ -0,0 +1,133 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import { toast } from 'sonner'
import type { SupporterVoteIntentionFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
} from '@/components/ui/field'
import { Spinner } from '@/components/ui/Spinner'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
export type VoteIntentionControlAction = (
state: SupporterVoteIntentionFormState,
formData: FormData,
) => Promise<SupporterVoteIntentionFormState>
export const VoteIntentionControl = ({
supporterId,
currentValue,
hasVoteIntentionConsent,
voteIntentionConsentConfigured,
action,
}: {
supporterId: number
currentValue: SupporterVoteIntention | null
hasVoteIntentionConsent: boolean
voteIntentionConsentConfigured: boolean
action: VoteIntentionControlAction
}) => {
const [state, formAction, pending] = useActionState(action, {})
const [localConsentAccepted, setLocalConsentAccepted] = useState(false)
const [selected, setSelected] = useState<SupporterVoteIntention | ''>(currentValue ?? '')
const canEdit =
hasVoteIntentionConsent || (localConsentAccepted && voteIntentionConsentConfigured)
useEffect(() => {
if (state.status !== 'success') return
toast.success(state.message)
if (state.voteIntention) setSelected(state.voteIntention)
}, [state.message, state.status, state.voteIntention])
return (
<section className="rounded-[6px] border bg-card p-4" aria-labelledby="vote-intention-title">
<h2 id="vote-intention-title" className="font-medium">
Intenção de voto
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Dado sensível — disponível somente após confirmação do consentimento LGPD.
</p>
{!hasVoteIntentionConsent ? (
voteIntentionConsentConfigured ? (
<Field orientation="horizontal" className="mt-4">
<Checkbox
id="vote-intention-consent-accepted"
checked={localConsentAccepted}
onCheckedChange={(checked) => setLocalConsentAccepted(checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor="vote-intention-consent-accepted">
Confirmo o consentimento destacado para registrar intenção de voto
</FieldLabel>
<FieldDescription>
Marque somente se o titular autorizou expressamente este tratamento.
</FieldDescription>
</FieldContent>
</Field>
) : (
<Alert variant="destructive" className="mt-4">
<AlertTitle>Consentimento não configurado</AlertTitle>
<AlertDescription>
O texto de intenção de voto ainda não foi cadastrado no admin.
</AlertDescription>
</Alert>
)
) : null}
<form action={formAction} className="mt-4 flex flex-col gap-4">
<input type="hidden" name="id" value={supporterId} />
<input type="hidden" name="voteIntention" value={selected} />
{hasVoteIntentionConsent || localConsentAccepted ? (
<input type="hidden" name="voteIntentionConsentAccepted" value="true" />
) : null}
<ToggleGroup
type="single"
value={selected}
onValueChange={(value) => setSelected((value as SupporterVoteIntention) ?? '')}
variant="outline"
className="flex w-full flex-wrap"
disabled={!canEdit || pending}
>
{Object.entries(supporterVoteIntentionLabels).map(([value, label]) => (
<ToggleGroupItem key={value} value={value} className="min-h-11 flex-1">
{label}
</ToggleGroupItem>
))}
</ToggleGroup>
{!canEdit ? (
<p className="text-sm text-muted-foreground">
Disponível somente após confirmação do consentimento LGPD.
</p>
) : null}
{state.message && state.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<Button
type="submit"
className="min-h-11 w-fit"
disabled={!canEdit || !selected || pending}
>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Salvando…' : 'Salvar intenção'}
</Button>
</form>
</section>
)
}

View file

@ -1,6 +1,7 @@
import { HomeIcon, Layers3Icon, type LucideIcon } from 'lucide-react'
import { HomeIcon, Layers3Icon, UsersIcon, type LucideIcon } from 'lucide-react'
import type { CampaignUser } from '@/payload-types'
import { canAccessSupporterArea } from '@/utilities/supporterUi'
export type CampaignNavItem = {
title: string
@ -19,14 +20,21 @@ export const campaignNav: CampaignNavItem[] = [
href: '/campanha/nucleos',
icon: Layers3Icon,
},
{
title: 'Apoiadores',
href: '/campanha/apoiadores',
icon: UsersIcon,
},
]
export const getCampaignNav = (role: CampaignUser['role']): CampaignNavItem[] =>
campaignNav.map((item) =>
item.href === '/campanha/nucleos' && role === 'lideranca'
? { ...item, title: 'Meus núcleos' }
: item,
)
campaignNav
.filter((item) => item.href !== '/campanha/apoiadores' || canAccessSupporterArea(role))
.map((item) =>
item.href === '/campanha/nucleos' && role === 'lideranca'
? { ...item, title: 'Meus núcleos' }
: item,
)
/** Home matches only exactly; other items also match nested paths. */
export const isCampaignNavActive = (pathname: string, href: string): boolean => {

View file

@ -0,0 +1,107 @@
import { z } from 'zod'
import { bahiaMunicipalities } from '@/lib/bahiaTerritories'
import {
brazilianMobile,
optionalPersistedEmail,
positiveRelationshipId,
trimmedNullableText,
} from '@/lib/schemas/primitives'
import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
export const supporterVoteIntentions = [
'certo',
'tende_a_certo',
'indeciso',
'outro',
] as const
export type SupporterVoteIntention = (typeof supporterVoteIntentions)[number]
export const isSupporterVoteIntention = (value: unknown): value is SupporterVoteIntention =>
typeof value === 'string' &&
(supporterVoteIntentions as readonly string[]).includes(value)
const canonicalMunicipalityBySearchValue = new Map(
bahiaMunicipalities.map((city) => [normalizeSearchPhrase(city), city]),
)
export const resolveBahiaMunicipality = (value: string | undefined | null): string | null => {
if (typeof value !== 'string') return null
const trimmed = value.trim()
if (!trimmed) return null
return canonicalMunicipalityBySearchValue.get(normalizeSearchPhrase(trimmed)) ?? null
}
const optionalBahiaCity = z
.union([z.string().trim(), z.literal(''), z.null()])
.optional()
.transform((value, context) => {
if (value === undefined || value === null || value === '') return undefined
const city = resolveBahiaMunicipality(value)
if (!city) {
context.addIssue({
code: 'custom',
message: 'Município não reconhecido na Bahia.',
})
return z.NEVER
}
return city
})
export const supporterCreateSchema = z
.object({
name: z.string().trim().min(2).max(120),
phone: brazilianMobile,
email: optionalPersistedEmail,
city: optionalBahiaCity,
nucleus: positiveRelationshipId.optional(),
voteIntention: z.enum(supporterVoteIntentions).optional(),
consentAccepted: z.literal(true, {
error: 'É necessário aceitar o consentimento de cadastro.',
}),
voteIntentionConsentAccepted: z.boolean().optional(),
})
.superRefine((data, context) => {
if (data.voteIntention && !data.voteIntentionConsentAccepted) {
context.addIssue({
code: 'custom',
path: ['voteIntentionConsentAccepted'],
message: 'É necessário aceitar o consentimento de intenção de voto.',
})
}
})
export const supporterVoteIntentionSchema = z.object({
id: positiveRelationshipId,
voteIntention: z.enum(supporterVoteIntentions),
voteIntentionConsentAccepted: z.literal(true, {
error: 'É necessário aceitar o consentimento de intenção de voto.',
}),
})
export const supporterImportConfirmSchema = z.object({
operatorAttested: z.literal(true, {
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),
})
export const supporterRemoveSchema = z.object({
id: positiveRelationshipId,
})
export type SupporterCreateInput = z.input<typeof supporterCreateSchema>
export type SupporterVoteIntentionInput = z.input<typeof supporterVoteIntentionSchema>
export type SupporterImportConfirmInput = z.input<typeof supporterImportConfirmSchema>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,151 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Adds the `supporter` join collection (Contact ↔ campaign, optional nucleus).
*
* Hand-trimmed from the Payload-generated diff: the generator also tried to
* recreate `electoral_nucleus_texts` and drop legacy scalar territory columns
* that were already handled by `20260718_190559_territorio_multi_municipio_bairro`.
* Those drift statements were removed so this migration is idempotent against
* databases that already ran the territory migration.
*
* Uniqueness uses `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)` so a
* contact may have at most one supporter row without a nucleus (Postgres ≥15).
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'enum_supporter_vote_intention'
) THEN
CREATE TYPE "public"."enum_supporter_vote_intention" AS ENUM(
'certo',
'tende_a_certo',
'indeciso',
'outro'
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'enum_supporter_source'
) THEN
CREATE TYPE "public"."enum_supporter_source" AS ENUM(
'import_csv',
'manual',
'convite',
'evento'
);
END IF;
END $$;
CREATE TABLE IF NOT EXISTS "supporter" (
"id" serial PRIMARY KEY NOT NULL,
"contact_id" integer NOT NULL,
"nucleus_id" integer,
"vote_intention" "enum_supporter_vote_intention",
"consent_id" integer,
"consent_content_hash" varchar,
"consented_at" timestamp(3) with time zone,
"vote_intention_consent_id" integer,
"vote_intention_consent_content_hash" varchar,
"vote_intention_consented_at" timestamp(3) with time zone,
"source" "enum_supporter_source" DEFAULT 'manual' NOT NULL,
"consent_note" varchar,
"notes" varchar,
"created_by_id" integer,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'supporter_contact_id_contact_id_fk'
) THEN
ALTER TABLE "supporter"
ADD CONSTRAINT "supporter_contact_id_contact_id_fk"
FOREIGN KEY ("contact_id") REFERENCES "public"."contact"("id")
ON DELETE set null ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'supporter_nucleus_id_electoral_nucleus_id_fk'
) THEN
ALTER TABLE "supporter"
ADD CONSTRAINT "supporter_nucleus_id_electoral_nucleus_id_fk"
FOREIGN KEY ("nucleus_id") REFERENCES "public"."electoral_nucleus"("id")
ON DELETE set null ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'supporter_consent_id_consent_id_fk'
) THEN
ALTER TABLE "supporter"
ADD CONSTRAINT "supporter_consent_id_consent_id_fk"
FOREIGN KEY ("consent_id") REFERENCES "public"."consent"("id")
ON DELETE set null ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'supporter_vote_intention_consent_id_consent_id_fk'
) THEN
ALTER TABLE "supporter"
ADD CONSTRAINT "supporter_vote_intention_consent_id_consent_id_fk"
FOREIGN KEY ("vote_intention_consent_id") REFERENCES "public"."consent"("id")
ON DELETE set null ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'supporter_created_by_id_campaign_user_id_fk'
) THEN
ALTER TABLE "supporter"
ADD CONSTRAINT "supporter_created_by_id_campaign_user_id_fk"
FOREIGN KEY ("created_by_id") REFERENCES "public"."campaign_user"("id")
ON DELETE set null ON UPDATE no action;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "supporter_contact_idx" ON "supporter" USING btree ("contact_id");
CREATE INDEX IF NOT EXISTS "supporter_nucleus_idx" ON "supporter" USING btree ("nucleus_id");
CREATE INDEX IF NOT EXISTS "supporter_vote_intention_idx" ON "supporter" USING btree ("vote_intention");
CREATE INDEX IF NOT EXISTS "supporter_consent_idx" ON "supporter" USING btree ("consent_id");
CREATE INDEX IF NOT EXISTS "supporter_vote_intention_consent_idx" ON "supporter" USING btree ("vote_intention_consent_id");
CREATE INDEX IF NOT EXISTS "supporter_source_idx" ON "supporter" USING btree ("source");
CREATE INDEX IF NOT EXISTS "supporter_created_by_idx" ON "supporter" USING btree ("created_by_id");
CREATE INDEX IF NOT EXISTS "supporter_updated_at_idx" ON "supporter" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "supporter_created_at_idx" ON "supporter" USING btree ("created_at");
CREATE UNIQUE INDEX IF NOT EXISTS "supporter_contact_nucleus_nulls_not_distinct_idx"
ON "supporter" ("contact_id", "nucleus_id") NULLS NOT DISTINCT;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "supporter_id" integer;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'payload_locked_documents_rels_supporter_fk'
) THEN
ALTER TABLE "payload_locked_documents_rels"
ADD CONSTRAINT "payload_locked_documents_rels_supporter_fk"
FOREIGN KEY ("supporter_id") REFERENCES "public"."supporter"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_supporter_id_idx"
ON "payload_locked_documents_rels" USING btree ("supporter_id");
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_supporter_fk";
DROP INDEX IF EXISTS "payload_locked_documents_rels_supporter_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "supporter_id";
DROP TABLE IF EXISTS "supporter" CASCADE;
DROP TYPE IF EXISTS "public"."enum_supporter_vote_intention";
DROP TYPE IF EXISTS "public"."enum_supporter_source";
`)
}

View file

@ -6,6 +6,7 @@ import * as migration_20260716_010420_add_campaign_user from './20260716_010420_
import * as migration_20260718_010733_consolidate_campaign_schema from './20260718_010733_consolidate_campaign_schema';
import * as migration_20260718_190559_territorio_multi_municipio_bairro from './20260718_190559_territorio_multi_municipio_bairro';
import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results';
import * as migration_20260718_222656_add_supporter from './20260718_222656_add_supporter';
export const migrations = [
{
@ -48,4 +49,9 @@ export const migrations = [
down: migration_20260718_195854_add_election_results.down,
name: '20260718_195854_add_election_results',
},
]
{
up: migration_20260718_222656_add_supporter.up,
down: migration_20260718_222656_add_supporter.down,
name: '20260718_222656_add_supporter'
},
];

View file

@ -73,6 +73,7 @@ export interface Config {
campaignInvite: CampaignInvite;
electoralNucleus: ElectoralNucleus;
leadership: Leadership;
supporter: Supporter;
nucleusUpdate: NucleusUpdate;
electionTally: ElectionTally;
electionCandidateVote: ElectionCandidateVote;
@ -98,6 +99,7 @@ export interface Config {
campaignInvite: CampaignInviteSelect<false> | CampaignInviteSelect<true>;
electoralNucleus: ElectoralNucleusSelect<false> | ElectoralNucleusSelect<true>;
leadership: LeadershipSelect<false> | LeadershipSelect<true>;
supporter: SupporterSelect<false> | SupporterSelect<true>;
nucleusUpdate: NucleusUpdateSelect<false> | NucleusUpdateSelect<true>;
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
electionCandidateVote: ElectionCandidateVoteSelect<false> | ElectionCandidateVoteSelect<true>;
@ -449,6 +451,28 @@ export interface Consent {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supporter".
*/
export interface Supporter {
id: number;
contact: number | Contact;
nucleus?: (number | null) | ElectoralNucleus;
voteIntention?: ('certo' | 'tende_a_certo' | 'indeciso' | 'outro') | null;
consent?: (number | null) | Consent;
consentContentHash?: string | null;
consentedAt?: string | null;
voteIntentionConsent?: (number | null) | Consent;
voteIntentionConsentContentHash?: string | null;
voteIntentionConsentedAt?: string | null;
source: 'import_csv' | 'manual' | 'convite' | 'evento';
consentNote?: string | null;
notes?: string | null;
createdBy?: (number | null) | CampaignUser;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "nucleusUpdate".
@ -822,6 +846,10 @@ export interface PayloadLockedDocument {
relationTo: 'leadership';
value: number | Leadership;
} | null)
| ({
relationTo: 'supporter';
value: number | Supporter;
} | null)
| ({
relationTo: 'nucleusUpdate';
value: number | NucleusUpdate;
@ -1074,6 +1102,27 @@ export interface LeadershipSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supporter_select".
*/
export interface SupporterSelect<T extends boolean = true> {
contact?: T;
nucleus?: T;
voteIntention?: T;
consent?: T;
consentContentHash?: T;
consentedAt?: T;
voteIntentionConsent?: T;
voteIntentionConsentContentHash?: T;
voteIntentionConsentedAt?: T;
source?: T;
consentNote?: T;
notes?: T;
createdBy?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "nucleusUpdate_select".

View file

@ -22,6 +22,7 @@ import { Petition } from './collections/Petition'
import { Post } from './collections/Post'
import { Signature } from './collections/Signature'
import { Subscription } from './collections/Subscription'
import { Supporter } from './collections/Supporter'
import { Tag } from './collections/Tag'
import { Users } from './collections/Users'
import { HomePage } from './globals/HomePage'
@ -48,6 +49,7 @@ export default buildConfig({
CampaignInvite,
ElectoralNucleus,
Leadership,
Supporter,
NucleusUpdate,
ElectionTally,
ElectionCandidateVote,

View file

@ -280,6 +280,40 @@ export const canReadLeadershipInternal: FieldAccess = async ({ req }) => {
export const canManageLeadershipInternal: FieldAccess = canReadLeadershipInternal
export const canCreateSupporter: Access = async ({ data, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignGeneral(currentUser)) return true
if (currentUser?.role !== 'coordenador') return false
const nucleus = relationshipId(data?.nucleus)
if (!nucleus) return false
const nucleusIDs = await getAccessibleNucleusIds(req, currentUser)
return nucleusIDs?.includes(nucleus) ?? false
}
export const canReadSupporter: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignGeneral(currentUser)) return true
if (currentUser?.role !== 'coordenador') return false
const nucleusIDs = await getAccessibleNucleusIds(req, currentUser)
return {
nucleus: {
in: nucleusIDs ?? [],
},
}
}
export const canManageSupporter: Access = canReadSupporter
export const canDeleteSupporter: Access = ({ req }) => isPayloadAdmin(req.user)
export const canCreateNucleusCoordinators: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
@ -478,7 +512,7 @@ export const getAccessibleContactIds = async (
}
const nucleusIDs = await getAccessibleNucleusIds(req, currentUser)
const where =
const leadershipWhere =
currentUser.role === 'lideranca'
? {
and: [{ user: { equals: currentUser.id } }, { supportStatus: { equals: 'engajado' } }],
@ -490,24 +524,48 @@ export const getAccessibleContactIds = async (
}
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
const result = await find({
collection: 'leadership',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where,
})
const collections = req.payload.collections as Record<string, unknown>
const contactIDs: ContactID[] = []
const ids = [
...new Set(
result.docs
.map((doc) => relationshipId(doc.contact))
.filter((id): id is number => id !== null),
),
]
if (collections.leadership) {
const leadershipResult = await find({
collection: 'leadership',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where: leadershipWhere,
})
for (const doc of leadershipResult.docs) {
const id = relationshipId(doc.contact)
if (id !== null) contactIDs.push(id)
}
}
if (currentUser.role === 'coordenador' && collections.supporter) {
const supporterResult = await find({
collection: 'supporter',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where: {
nucleus: {
in: nucleusIDs ?? [],
},
},
})
for (const doc of supporterResult.docs) {
const id = relationshipId(doc.contact)
if (id !== null) contactIDs.push(id)
}
}
const ids = [...new Set(contactIDs)]
context[cacheKey] = ids
return ids

View file

@ -6,12 +6,19 @@ import type { Consent } from '@/payload-types'
import { CAMPAIGN_INVITE_CONSENT_KEY } from '@/utilities/campaignInvite'
import { hashConsentContent } from '@/utilities/consentContentHash'
export type LeadershipConsentDescriptor = {
export const SUPPORTER_REGISTRATION_CONSENT_KEY = 'apoiador-cadastro'
export const SUPPORTER_VOTE_INTENTION_CONSENT_KEY = 'apoiador-intencao-voto'
export type ConsentDescriptor = {
id: number
text: Consent['text']
contentHash: string
key: string
}
/** @deprecated Prefer ConsentDescriptor — kept for leadership call sites. */
export type LeadershipConsentDescriptor = Omit<ConsentDescriptor, 'key'>
type ConsentRequest = { transactionID?: number | string }
export type InviteConsentResolution =
@ -53,17 +60,18 @@ export const resolveInviteConsent = ({
}
}
export const getLeadershipConsent = async (
export const getConsentByKey = async (
payload: Payload,
key: string,
req?: ConsentRequest,
): Promise<LeadershipConsentDescriptor | null> => {
): Promise<ConsentDescriptor | null> => {
const result = await payload.find({
collection: 'consent',
where: { key: { equals: CAMPAIGN_INVITE_CONSENT_KEY } },
where: { key: { equals: key } },
depth: 0,
limit: 1,
pagination: false,
select: { text: true },
select: { text: true, key: true },
overrideAccess: true,
req,
})
@ -74,15 +82,61 @@ export const getLeadershipConsent = async (
id: consent.id,
text: consent.text,
contentHash: hashConsentContent(consent.text ?? null),
key,
}
}
export const requireConsentByKey = async (
payload: Payload,
key: string,
req?: ConsentRequest,
message = 'Consentimento ainda não configurado.',
): Promise<ConsentDescriptor> => {
const consent = await getConsentByKey(payload, key, req)
if (!consent) throw new Error(message)
return consent
}
export const getLeadershipConsent = async (
payload: Payload,
req?: ConsentRequest,
): Promise<LeadershipConsentDescriptor | null> => {
const consent = await getConsentByKey(payload, CAMPAIGN_INVITE_CONSENT_KEY, req)
if (!consent) return null
return { id: consent.id, text: consent.text, contentHash: consent.contentHash }
}
export const requireLeadershipConsent = async (
payload: Payload,
req?: ConsentRequest,
message = 'Consentimento ainda não configurado.',
): Promise<LeadershipConsentDescriptor> => {
const consent = await getLeadershipConsent(payload, req)
if (!consent) throw new Error(message)
return consent
const consent = await requireConsentByKey(payload, CAMPAIGN_INVITE_CONSENT_KEY, req, message)
return { id: consent.id, text: consent.text, contentHash: consent.contentHash }
}
export const getSupporterRegistrationConsent = (
payload: Payload,
req?: ConsentRequest,
): Promise<ConsentDescriptor | null> =>
getConsentByKey(payload, SUPPORTER_REGISTRATION_CONSENT_KEY, req)
export const requireSupporterRegistrationConsent = (
payload: Payload,
req?: ConsentRequest,
message = 'Consentimento de cadastro de apoiador ainda não configurado.',
): Promise<ConsentDescriptor> =>
requireConsentByKey(payload, SUPPORTER_REGISTRATION_CONSENT_KEY, req, message)
export const getSupporterVoteIntentionConsent = (
payload: Payload,
req?: ConsentRequest,
): Promise<ConsentDescriptor | null> =>
getConsentByKey(payload, SUPPORTER_VOTE_INTENTION_CONSENT_KEY, req)
export const requireSupporterVoteIntentionConsent = (
payload: Payload,
req?: ConsentRequest,
message = 'Consentimento de intenção de voto ainda não configurado.',
): Promise<ConsentDescriptor> =>
requireConsentByKey(payload, SUPPORTER_VOTE_INTENTION_CONSENT_KEY, req, message)

View file

@ -0,0 +1,208 @@
import type { Payload } from 'payload'
import type { CampaignUser, Consent, ElectoralNucleus, Supporter } from '@/payload-types'
import {
getSupporterRegistrationConsent,
getSupporterVoteIntentionConsent,
} from '@/utilities/campaignConsent'
import {
toSupporterDetailViewModel,
type SupporterDetailViewModel,
type SupporterListOverviewViewModel,
type SupporterNucleusOption,
} from '@/utilities/supporterViewModels'
import {
buildSupporterListWhere,
resolveSupporterListUrl,
supporterPageSize,
type SupporterListState,
} from '@/utilities/supporterUi'
type SupporterListSearchParams = Record<string, string | string[] | undefined>
export class SupporterNotFoundError extends Error {
override name = 'SupporterNotFoundError'
constructor() {
super('Apoiador não encontrado.')
}
}
const supporterListSelect = {
voteIntention: true,
contact: true,
nucleus: true,
} as const
const supporterDetailSelect = {
voteIntention: true,
source: true,
consentedAt: true,
voteIntentionConsentedAt: true,
createdAt: true,
contact: true,
nucleus: true,
createdBy: true,
} as const
const nucleusOptionsSelect = {
name: true,
slug: true,
} as const
export const loadAccessibleNucleusOptions = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
): Promise<SupporterNucleusOption[]> => {
const result = await payload.find({
collection: 'electoralNucleus',
where: { status: { equals: 'ativo' } },
depth: 0,
pagination: false,
sort: 'name',
select: nucleusOptionsSelect,
user,
overrideAccess: false,
})
return result.docs.map((nucleus) => {
const doc = nucleus as ElectoralNucleus
return { id: doc.id, name: doc.name, slug: doc.slug }
})
}
export const loadSupporterListPageData = async (
payload: Pick<Payload, 'find' | 'count'>,
user: CampaignUser,
searchParams: Promise<SupporterListSearchParams> | SupporterListSearchParams,
) => {
const rawSearchParams = await searchParams
const canonicalUrl = resolveSupporterListUrl(rawSearchParams)
const where = buildSupporterListWhere(canonicalUrl.state)
const result = await payload.find({
collection: 'supporter',
depth: 1,
limit: supporterPageSize,
page: canonicalUrl.state.page,
sort: '-updatedAt',
where,
select: supporterListSelect,
user,
overrideAccess: false,
})
const resolvedUrl = resolveSupporterListUrl(rawSearchParams, result.totalPages)
return {
result,
state: resolvedUrl.state,
redirectHref: resolvedUrl.redirectHref ?? canonicalUrl.redirectHref,
}
}
export const loadSupporterListOverviewData = async (
payload: Pick<Payload, 'count'>,
user: CampaignUser,
state: SupporterListState,
): Promise<SupporterListOverviewViewModel | null> => {
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,
}
}
export const loadSupporterDetailPageData = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
supporterId: number,
): Promise<SupporterDetailViewModel> => {
const result = await payload.find({
collection: 'supporter',
where: { id: { equals: supporterId } },
depth: 1,
limit: 1,
pagination: false,
select: supporterDetailSelect,
user,
overrideAccess: false,
})
const supporter = result.docs[0]
if (!supporter) throw new SupporterNotFoundError()
return toSupporterDetailViewModel(supporter as Supporter)
}
export type SupporterCreatePageData = {
nucleusOptions: SupporterNucleusOption[]
registrationConsentConfigured: boolean
voteIntentionConsentConfigured: boolean
requireNucleus: boolean
}
export const loadSupporterCreatePageData = async (
payload: Payload,
user: CampaignUser,
): Promise<SupporterCreatePageData> => {
const [nucleusOptions, registrationConsent, voteIntentionConsent] = await Promise.all([
loadAccessibleNucleusOptions(payload, user),
getSupporterRegistrationConsent(payload),
getSupporterVoteIntentionConsent(payload),
])
return {
nucleusOptions,
registrationConsentConfigured: Boolean(registrationConsent),
voteIntentionConsentConfigured: Boolean(voteIntentionConsent),
requireNucleus: user.role === 'coordenador',
}
}
export type SupporterDetailConsentData = {
registrationConsent: Consent['text'] | null
voteIntentionConsent: Consent['text'] | null
}
export const loadSupporterDetailConsentData = async (
payload: Payload,
): Promise<SupporterDetailConsentData> => {
const [registrationConsent, voteIntentionConsent] = await Promise.all([
getSupporterRegistrationConsent(payload),
getSupporterVoteIntentionConsent(payload),
])
return {
registrationConsent: registrationConsent?.text ?? null,
voteIntentionConsent: voteIntentionConsent?.text ?? null,
}
}

View file

@ -0,0 +1,185 @@
import type { Where } from 'payload'
import {
isSupporterVoteIntention,
resolveBahiaMunicipality,
type SupporterVoteIntention,
} from '@/lib/schemas/supporter'
import type { CampaignUser, Supporter } from '@/payload-types'
import { normalizeBrazilianPhone } from '@/utilities/phone'
export const supporterPageSize = 25
export const supporterVoteIntentionLabels: Record<SupporterVoteIntention, string> = {
certo: 'Certo',
tende_a_certo: 'Tende a certo',
indeciso: 'Indeciso',
outro: 'Outro',
}
export const supporterSourceLabels: Record<Supporter['source'], string> = {
import_csv: 'Importação CSV',
manual: 'Cadastro manual',
convite: 'Convite',
evento: 'Evento',
}
export type SupporterListState = {
page: number
q?: string
voteIntention?: SupporterVoteIntention
city?: string
nucleus?: number
}
type RawSearchParams = Record<string, string | string[] | undefined>
export const supporterListParamNames = ['q', 'voteIntention', 'city', 'nucleus', 'page'] as const
const supporterListParamNameSet = new Set<string>(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))
const city = resolveBahiaMunicipality(firstValue(params.city)) ?? undefined
const rawVoteIntention = firstValue(params.voteIntention)
const voteIntention = isSupporterVoteIntention(rawVoteIntention) ? rawVoteIntention : undefined
const nucleus = strictDecimalInteger(firstValue(params.nucleus))
return {
page: rawPage ?? 1,
...(q ? { q } : {}),
...(voteIntention ? { voteIntention } : {}),
...(city ? { city } : {}),
...(nucleus ? { nucleus } : {}),
}
}
export const buildSupporterListWhere = (state: SupporterListState): Where => {
const filters: Where[] = []
if (state.q) {
const searchFilters: Where[] = [
{ 'contact.name': { contains: state.q } },
{ 'contact.city': { contains: state.q } },
]
const normalizedPhone = normalizeBrazilianPhone(state.q)
if (normalizedPhone) {
searchFilters.push({ 'contact.phone': { equals: normalizedPhone } })
} else if (/\d/.test(state.q)) {
searchFilters.push({ 'contact.phone': { contains: state.q.replace(/\D/g, '') } })
}
filters.push({ or: searchFilters })
}
if (state.voteIntention) {
filters.push({ voteIntention: { equals: state.voteIntention } })
}
if (state.city) {
filters.push({ 'contact.city': { equals: state.city } })
}
if (state.nucleus) {
filters.push({ nucleus: { equals: state.nucleus } })
}
return filters.length ? { and: filters } : {}
}
export const buildSupporterListSearchParams = (
state: SupporterListState,
page = state.page,
): URLSearchParams => {
const canonicalState = parseSupporterListParams({
page: String(page),
q: state.q,
voteIntention: state.voteIntention,
city: state.city,
nucleus: state.nucleus === undefined ? undefined : String(state.nucleus),
})
const params = new URLSearchParams()
if (canonicalState.q) params.set('q', canonicalState.q)
if (canonicalState.voteIntention) params.set('voteIntention', canonicalState.voteIntention)
if (canonicalState.city) params.set('city', canonicalState.city)
if (canonicalState.nucleus) params.set('nucleus', String(canonicalState.nucleus))
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
return params
}
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 resolveSupporterListUrl = (
params: RawSearchParams,
totalPages?: number,
): {
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 } : {}),
}
}
export const getSupporterScopeLabel = (total: number): string =>
`${total} ${total === 1 ? 'apoiador nos seus núcleos' : 'apoiadores nos seus núcleos'}`
export const canAccessSupporterArea = (role: CampaignUser['role']): boolean =>
role === 'geral' || role === 'coordenador'

View file

@ -0,0 +1,103 @@
import { territoryForCity } from '@/lib/bahiaTerritories'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import type { CampaignUser, Contact, ElectoralNucleus, Supporter } from '@/payload-types'
import { formatBrazilianPhoneInput } from '@/utilities/phone'
import { isPopulatedRelationship } from '@/utilities/relationship'
import { supporterSourceLabels } from '@/utilities/supporterUi'
export type SupporterNucleusOption = {
id: number
name: string
slug: string
}
export type SupporterListItemViewModel = {
id: number
name: string
phone: string
city: string | null
nucleusName: string | null
nucleusSlug: string | null
voteIntention: SupporterVoteIntention | null
}
export type SupporterListOverviewViewModel = {
total: number
certoAndTende: number
indeciso: number
}
export type SupporterDetailViewModel = {
id: number
name: string
phone: string
phoneDisplay: string
email: string | null
city: string | null
territory: string | null
nucleusName: string | null
nucleusSlug: string | null
voteIntention: SupporterVoteIntention | null
sourceLabel: string
consentedAt: string | null
createdByName: string | null
hasVoteIntentionConsent: boolean
createdAt: string
}
const contactFromSupporter = (supporter: { contact: Supporter['contact'] }): Contact | null =>
isPopulatedRelationship<Contact>(supporter.contact) ? supporter.contact : null
const nucleusFromSupporter = (supporter: { nucleus?: Supporter['nucleus'] }): ElectoralNucleus | null =>
isPopulatedRelationship<ElectoralNucleus>(supporter.nucleus) ? supporter.nucleus : null
export const toSupporterListItemViewModel = (
supporter: Pick<Supporter, 'id' | 'voteIntention' | 'contact' | 'nucleus'>,
): SupporterListItemViewModel => {
const contact = contactFromSupporter(supporter)
const nucleus = nucleusFromSupporter(supporter)
return {
id: supporter.id,
name: contact?.name ?? 'Contato sem nome',
phone: contact?.phone ?? '',
city: contact?.city ?? null,
nucleusName: nucleus?.name ?? null,
nucleusSlug: nucleus?.slug ?? null,
voteIntention: supporter.voteIntention ?? null,
}
}
export const toSupporterDetailViewModel = (supporter: Supporter): SupporterDetailViewModel => {
const contact = contactFromSupporter(supporter)
const nucleus = nucleusFromSupporter(supporter)
const creator = isPopulatedRelationship<CampaignUser>(supporter.createdBy)
? supporter.createdBy
: null
const city = contact?.city ?? null
const phone = contact?.phone ?? ''
return {
id: supporter.id,
name: contact?.name ?? 'Contato sem nome',
phone,
phoneDisplay: phone ? formatBrazilianPhoneInput(phone) : '',
email: contact?.email ?? null,
city,
territory: city ? (territoryForCity(city) ?? null) : null,
nucleusName: nucleus?.name ?? null,
nucleusSlug: nucleus?.slug ?? null,
voteIntention: supporter.voteIntention ?? null,
sourceLabel: supporterSourceLabels[supporter.source],
consentedAt: supporter.consentedAt ?? null,
createdByName: creator?.name ?? null,
hasVoteIntentionConsent: Boolean(supporter.voteIntentionConsentedAt),
createdAt: supporter.createdAt,
}
}
export const parseSupporterId = (value: string): number | null => {
if (!/^[1-9]\d*$/.test(value)) return null
const id = Number(value)
return Number.isSafeInteger(id) ? id : null
}

View file

@ -12,6 +12,7 @@ import type {
ElectoralNucleus,
Leadership,
NucleusUpdate,
Supporter,
User,
} from '@/payload-types'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
@ -21,6 +22,7 @@ type CampaignCollection =
| 'campaignInvite'
| 'nucleusUpdate'
| 'leadership'
| 'supporter'
| 'electoralNucleus'
| 'contact'
| 'campaignUser'
@ -75,6 +77,7 @@ const emptyOwnedIDs = (): OwnedIDs => ({
campaignInvite: new Set(),
nucleusUpdate: new Set(),
leadership: new Set(),
supporter: new Set(),
electoralNucleus: new Set(),
contact: new Set(),
campaignUser: new Set(),
@ -384,6 +387,40 @@ export class CampaignFixtures {
return leadership
}
async createSupporter(
input: Partial<
Pick<
Supporter,
| 'contact'
| 'nucleus'
| 'voteIntention'
| 'source'
| 'consent'
| 'consentContentHash'
| 'consentedAt'
| 'notes'
| 'consentNote'
| 'createdBy'
>
> &
Pick<Supporter, 'contact'>,
): Promise<Supporter> {
const supporter = await this.rootPayload.create({
collection: 'supporter',
data: {
source: 'manual',
...input,
contact: relationshipID(input.contact),
...(input.nucleus ? { nucleus: relationshipID(input.nucleus) } : {}),
...(input.createdBy ? { createdBy: relationshipID(input.createdBy) } : {}),
...(input.consent ? { consent: relationshipID(input.consent) } : {}),
},
depth: 0,
})
this.own('supporter', supporter)
return supporter
}
async createNucleusUpdate(input: NucleusUpdateInput): Promise<NucleusUpdate> {
const update = await this.rootPayload.create({
collection: 'nucleusUpdate',
@ -497,6 +534,28 @@ export class CampaignFixtures {
for (const update of updates.docs) this.own('nucleusUpdate', update)
}
const contactIDs = [...this.owned.contact]
if (contactIDs.length > 0 || nucleusIDs.length > 0) {
const supporterConditions: Where[] = []
if (contactIDs.length > 0) supporterConditions.push({ contact: { in: contactIDs } })
if (nucleusIDs.length > 0) supporterConditions.push({ nucleus: { in: nucleusIDs } })
if (this.owned.supporter.size > 0) {
supporterConditions.push({ id: { in: [...this.owned.supporter] } })
}
if (supporterConditions.length > 0) {
const supporters = await this.rootPayload.find({
collection: 'supporter',
where: { or: supporterConditions },
depth: 0,
pagination: false,
})
for (const supporter of supporters.docs) {
this.own('supporter', supporter)
this.own('contact', relationshipID(supporter.contact))
}
}
}
const lockedDocumentConditions = [
['campaign_invite_id', this.owned.campaignInvite],
['campaign_user_id', this.owned.campaignUser],
@ -504,6 +563,7 @@ export class CampaignFixtures {
['contact_id', this.owned.contact],
['electoral_nucleus_id', this.owned.electoralNucleus],
['leadership_id', this.owned.leadership],
['supporter_id', this.owned.supporter],
['nucleus_update_id', this.owned.nucleusUpdate],
['users_id', this.owned.users],
]
@ -565,6 +625,7 @@ export class CampaignFixtures {
'campaignInvite',
'nucleusUpdate',
'leadership',
'supporter',
'electoralNucleus',
'contact',
'campaignUser',

View file

@ -0,0 +1,318 @@
// @vitest-environment node
import { beforeAll, describe, expect, it } from 'vitest'
import { getPayload, type Payload } from 'payload'
import {
confirmSupporterImportRecord,
createSupporterRecord,
previewSupporterImportText,
removeSupporterDataRecord,
setSupporterVoteIntentionRecord,
} from '@/app/(campaign)/campanha/actions/supporter'
import { supporterCreateSchema } from '@/lib/schemas/supporter'
import config from '@/payload.config'
import {
SUPPORTER_REGISTRATION_CONSENT_KEY,
SUPPORTER_VOTE_INTENTION_CONSENT_KEY,
} from '@/utilities/campaignConsent'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const ensureConsentByKey = async (
fixtures: ReturnType<typeof campaignFixtures>,
key: string,
) => {
const existing = await payload.find({
collection: 'consent',
where: { key: { equals: key } },
depth: 0,
limit: 1,
pagination: false,
})
if (existing.docs[0]) {
fixtures.own('consent', existing.docs[0].id)
return existing.docs[0]
}
return fixtures.createConsent({ key })
}
const ensureSupporterConsents = async (fixtures: ReturnType<typeof campaignFixtures>) => {
const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY)
const voteIntention = await ensureConsentByKey(fixtures, SUPPORTER_VOTE_INTENTION_CONSENT_KEY)
return { registration, voteIntention }
}
describe('campaign supporter domain', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('requires phone and registration consent on create schema', () => {
expect(
supporterCreateSchema.safeParse({
name: 'Maria Silva',
phone: '71999990000',
consentAccepted: true,
}).success,
).toBe(true)
expect(
supporterCreateSchema.safeParse({
name: 'Maria Silva',
phone: '71999990000',
}).success,
).toBe(false)
expect(
supporterCreateSchema.safeParse({
name: 'Maria Silva',
phone: 'invalid',
consentAccepted: true,
}).success,
).toBe(false)
})
it('fails closed when supporter registration consent is missing', async () => {
const fixtures = campaignFixtures()
const geral = await fixtures.createCampaignUser('geral')
const existing = await payload.find({
collection: 'consent',
where: { key: { equals: SUPPORTER_REGISTRATION_CONSENT_KEY } },
depth: 0,
limit: 1,
})
if (existing.docs[0]) {
await payload.delete({ collection: 'consent', id: existing.docs[0].id })
}
await expect(
createSupporterRecord(payload, geral, {
name: fixtures.value('Apoiador'),
phone: fixtures.phone(),
consentAccepted: true,
}),
).rejects.toThrow(/Consentimento de cadastro de apoiador ainda não configurado/)
})
it('creates a supporter with contact upsert and blocks leadership coexistence', async () => {
const fixtures = campaignFixtures()
await ensureSupporterConsents(fixtures)
const geral = await fixtures.createCampaignUser('geral')
const nucleus = await fixtures.createNucleus()
const phone = fixtures.phone()
const created = await createSupporterRecord(payload, geral, {
name: fixtures.value('Apoiador'),
phone,
city: 'Salvador',
consentAccepted: true,
})
fixtures.own('supporter', created.id)
fixtures.own('contact', created.contact)
expect(created.contactReused).toBe(false)
expect(created.source).toBe('manual')
const contact = await fixtures.createContact()
await fixtures.createLeadership({
contact: contact.id,
nucleus: nucleus.id,
createdBy: geral.id,
})
await expect(
createSupporterRecord(payload, geral, {
name: contact.name,
phone: contact.phone,
nucleus: nucleus.id,
consentAccepted: true,
}),
).rejects.toThrow(/já é liderança neste núcleo/)
})
it('scopes coordinator create to accessible nuclei and blocks nucleus-less create', async () => {
const fixtures = campaignFixtures()
await ensureSupporterConsents(fixtures)
const coordenador = await fixtures.createCampaignUser('coordenador')
const nucleus = await fixtures.createNucleus({
coordinators: [coordenador.id],
})
const otherNucleus = await fixtures.createNucleus()
await expect(
createSupporterRecord(payload, coordenador, {
name: fixtures.value('Apoiador'),
phone: fixtures.phone(),
consentAccepted: true,
}),
).rejects.toThrow(/sem núcleo/)
await expect(
createSupporterRecord(payload, coordenador, {
name: fixtures.value('Apoiador'),
phone: fixtures.phone(),
nucleus: otherNucleus.id,
consentAccepted: true,
}),
).rejects.toThrow()
const created = await createSupporterRecord(payload, coordenador, {
name: fixtures.value('Apoiador'),
phone: fixtures.phone(),
nucleus: nucleus.id,
consentAccepted: true,
})
fixtures.own('supporter', created.id)
fixtures.own('contact', created.contact)
expect(created.id).toBeGreaterThan(0)
})
it('requires highlighted vote-intention consent before setting intention', async () => {
const fixtures = campaignFixtures()
const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY)
const geral = await fixtures.createCampaignUser('geral')
const contact = await fixtures.createContact()
const supporter = await fixtures.createSupporter({
contact: contact.id,
consent: registration.id,
consentContentHash: 'hash',
consentedAt: new Date().toISOString(),
createdBy: geral.id,
})
const existingVoteConsent = await payload.find({
collection: 'consent',
where: { key: { equals: SUPPORTER_VOTE_INTENTION_CONSENT_KEY } },
depth: 0,
limit: 1,
})
if (existingVoteConsent.docs[0]) {
await payload.delete({
collection: 'consent',
id: existingVoteConsent.docs[0].id,
})
}
await expect(
setSupporterVoteIntentionRecord(payload, geral, {
id: supporter.id,
voteIntention: 'certo',
voteIntentionConsentAccepted: true,
}),
).rejects.toThrow(/Consentimento de intenção de voto ainda não configurado/)
await ensureConsentByKey(fixtures, SUPPORTER_VOTE_INTENTION_CONSENT_KEY)
const updated = await setSupporterVoteIntentionRecord(payload, geral, {
id: supporter.id,
voteIntention: 'certo',
voteIntentionConsentAccepted: true,
})
expect(updated.voteIntention).toBe('certo')
expect(updated.voteIntentionConsentedAt).toBeTruthy()
})
it('previews and confirms CSV import for geral only', async () => {
const fixtures = campaignFixtures()
await ensureSupporterConsents(fixtures)
const geral = await fixtures.createCampaignUser('geral')
const coordenador = await fixtures.createCampaignUser('coordenador')
const phoneOk = fixtures.phone()
const phoneDup = fixtures.phone()
const existing = await fixtures.createContact({ phone: phoneDup })
await fixtures.createSupporter({
contact: existing.id,
createdBy: geral.id,
})
const csv = [
'nome,telefone,municipio,intencao',
`Ana Silva,${phoneOk},Salvador,certo`,
`Bruno Costa,${phoneDup},Salvador,indeciso`,
'Carla,119999,Salvador,certo',
'Diego Souza,71988887777,Cidade Inventada,certo',
].join('\n')
await expect(previewSupporterImportText(payload, coordenador, csv)).rejects.toThrow(
/coordenação geral/,
)
const preview = await previewSupporterImportText(payload, geral, csv)
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,
}))
const result = await confirmSupporterImportRecord(payload, geral, {
operatorAttested: true,
rows: okRows,
})
expect(result.created).toBe(1)
const contact = await payload.find({
collection: 'contact',
where: { phone: { equals: phoneOk } },
depth: 0,
limit: 1,
overrideAccess: true,
})
expect(contact.totalDocs).toBe(1)
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,
})
expect(created.totalDocs).toBe(1)
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)
const geral = await fixtures.createCampaignUser('geral')
const phone = fixtures.phone()
const created = await createSupporterRecord(payload, geral, {
name: fixtures.value('Apoiador'),
phone,
consentAccepted: true,
})
const contactID =
typeof created.contact === 'number' ? created.contact : created.contact.id
fixtures.own('supporter', created.id)
fixtures.own('contact', contactID)
const removed = await removeSupporterDataRecord(payload, geral, { id: created.id })
expect(removed.removed).toBe(true)
expect(removed.contactAnonymized).toBe(true)
const contact = await payload.findByID({
collection: 'contact',
id: contactID,
depth: 0,
})
expect(contact.name).toBe('Titular removido')
expect(contact.phone).not.toBe(phone)
})
})

View file

@ -4,9 +4,13 @@ import { describe, expect, it, vi } from 'vitest'
import type { Payload } from 'payload'
import {
getConsentByKey,
getLeadershipConsent,
requireConsentByKey,
requireLeadershipConsent,
SUPPORTER_REGISTRATION_CONSENT_KEY,
} from '@/utilities/campaignConsent'
import { CAMPAIGN_INVITE_CONSENT_KEY } from '@/utilities/campaignInvite'
describe('campaign consent descriptor', () => {
it('returns the stable descriptor and propagates the transaction request', async () => {
@ -23,7 +27,33 @@ describe('campaign consent descriptor', () => {
text: { root: { children: [] } },
contentHash: 'd800986181e1730e945e028853848395d8c34ccb95b54abbbaa50cb9539a845b',
})
expect(find).toHaveBeenCalledWith(expect.objectContaining({ req }))
expect(find).toHaveBeenCalledWith(
expect.objectContaining({
req,
where: { key: { equals: CAMPAIGN_INVITE_CONSENT_KEY } },
}),
)
})
it('resolves arbitrary consent keys and fails closed when missing', async () => {
const find = vi
.fn()
.mockResolvedValueOnce({
docs: [{ id: 3, text: { root: { children: [] } }, key: SUPPORTER_REGISTRATION_CONSENT_KEY }],
})
.mockResolvedValue({ docs: [] })
const payload = { find } as unknown as Payload
await expect(getConsentByKey(payload, SUPPORTER_REGISTRATION_CONSENT_KEY)).resolves.toEqual({
id: 3,
text: { root: { children: [] } },
contentHash: 'd800986181e1730e945e028853848395d8c34ccb95b54abbbaa50cb9539a845b',
key: SUPPORTER_REGISTRATION_CONSENT_KEY,
})
await expect(getConsentByKey(payload, 'missing-key')).resolves.toBeNull()
await expect(
requireConsentByKey(payload, 'missing-key', undefined, 'Consentimento ausente.'),
).rejects.toThrow('Consentimento ausente.')
})
it('keeps nullable and required missing-consent semantics distinct', async () => {

View file

@ -27,14 +27,24 @@ import { matchesAtWordStart } from '@/utilities/wordStartFilter'
describe('campaign nucleus UI contracts', () => {
afterEach(cleanup)
it('keeps cycle-one navigation limited to home and nuclei', () => {
it('exposes home, nuclei, and supporters navigation scoped by role', () => {
expect(campaignNav.map(({ href, title }) => ({ href, title }))).toEqual([
{ href: '/campanha', title: 'Início' },
{ href: '/campanha/nucleos', title: 'Núcleos' },
{ href: '/campanha/apoiadores', title: 'Apoiadores' },
])
expect(getCampaignNav('geral').map(({ href }) => href)).toEqual([
'/campanha',
'/campanha/nucleos',
'/campanha/apoiadores',
])
expect(getCampaignNav('lideranca').map(({ href, title }) => ({ href, title }))).toEqual([
{ href: '/campanha', title: 'Início' },
{ href: '/campanha/nucleos', title: 'Meus núcleos' },
])
expect(getCampaignNav('lideranca')[1]?.title).toBe('Meus núcleos')
expect(isCampaignNavActive('/campanha/nucleos/nucleo-chapada', '/campanha/nucleos')).toBe(true)
expect(isCampaignNavActive('/campanha/nucleos', '/campanha')).toBe(false)
expect(isCampaignNavActive('/campanha/apoiadores/1', '/campanha/apoiadores')).toBe(true)
})
it('normalizes list URL state and rejects unsupported filter values', () => {

View file

@ -0,0 +1,71 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
resolveBahiaMunicipality,
supporterCreateSchema,
supporterImportConfirmSchema,
supporterVoteIntentionSchema,
} from '@/lib/schemas/supporter'
describe('supporter schemas', () => {
it('normalizes phone and optional BA municipality', () => {
const parsed = supporterCreateSchema.parse({
name: 'Maria da Silva',
phone: '+55 (71) 98888-7777',
city: 'salvador',
consentAccepted: true,
})
expect(parsed.phone).toBe('71988887777')
expect(parsed.city).toBe('Salvador')
expect(resolveBahiaMunicipality('feira de santana')).toBe('Feira de Santana')
expect(resolveBahiaMunicipality('Nowhere')).toBeNull()
})
it('requires highlighted consent when vote intention is set on create', () => {
expect(
supporterCreateSchema.safeParse({
name: 'Maria Silva',
phone: '71988887777',
voteIntention: 'certo',
consentAccepted: true,
}).success,
).toBe(false)
expect(
supporterCreateSchema.safeParse({
name: 'Maria Silva',
phone: '71988887777',
voteIntention: 'certo',
consentAccepted: true,
voteIntentionConsentAccepted: true,
}).success,
).toBe(true)
})
it('requires operator attestation for import confirm', () => {
expect(
supporterImportConfirmSchema.safeParse({
rows: [{ nome: 'Ana Silva', telefone: '71988887777' }],
}).success,
).toBe(false)
expect(
supporterImportConfirmSchema.parse({
operatorAttested: true,
rows: [{ nome: 'Ana Silva', telefone: '71988887777', intencao: 'certo' }],
}).rows[0]?.telefone,
).toBe('71988887777')
})
it('requires vote intention consent acceptance', () => {
expect(
supporterVoteIntentionSchema.safeParse({
id: 1,
voteIntention: 'indeciso',
}).success,
).toBe(false)
})
})