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:
parent
3b703d42a3
commit
b079daa9bb
39 changed files with 12651 additions and 112 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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.' }
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
177
src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx
Normal file
177
src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
115
src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts
Normal file
115
src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
47
src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx
Normal file
47
src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
src/app/(campaign)/campanha/(app)/apoiadores/page.tsx
Normal file
127
src/app/(campaign)/campanha/(app)/apoiadores/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
788
src/app/(campaign)/campanha/actions/supporter.ts
Normal file
788
src/app/(campaign)/campanha/actions/supporter.ts
Normal 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)
|
||||
}
|
||||
228
src/collections/Supporter.ts
Normal file
228
src/collections/Supporter.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
52
src/components/campaign/RemoveSupporterDataButton.tsx
Normal file
52
src/components/campaign/RemoveSupporterDataButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
229
src/components/campaign/SupporterFilters.tsx
Normal file
229
src/components/campaign/SupporterFilters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
310
src/components/campaign/SupporterForm.tsx
Normal file
310
src/components/campaign/SupporterForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
306
src/components/campaign/SupporterImportWizard.tsx
Normal file
306
src/components/campaign/SupporterImportWizard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
155
src/components/campaign/SupporterList.tsx
Normal file
155
src/components/campaign/SupporterList.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
31
src/components/campaign/SupporterListOverview.tsx
Normal file
31
src/components/campaign/SupporterListOverview.tsx
Normal 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>
|
||||
)
|
||||
61
src/components/campaign/SupporterPagination.tsx
Normal file
61
src/components/campaign/SupporterPagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
src/components/campaign/SupporterShareKit.tsx
Normal file
81
src/components/campaign/SupporterShareKit.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
src/components/campaign/VoteIntentionControl.tsx
Normal file
133
src/components/campaign/VoteIntentionControl.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
107
src/lib/schemas/supporter.ts
Normal file
107
src/lib/schemas/supporter.ts
Normal 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>
|
||||
8125
src/migrations/20260718_222656_add_supporter.json
Normal file
8125
src/migrations/20260718_222656_add_supporter.json
Normal file
File diff suppressed because it is too large
Load diff
151
src/migrations/20260718_222656_add_supporter.ts
Normal file
151
src/migrations/20260718_222656_add_supporter.ts
Normal 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";
|
||||
`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
208
src/utilities/supporterPageData.ts
Normal file
208
src/utilities/supporterPageData.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
185
src/utilities/supporterUi.ts
Normal file
185
src/utilities/supporterUi.ts
Normal 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'
|
||||
103
src/utilities/supporterViewModels.ts
Normal file
103
src/utilities/supporterViewModels.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
318
tests/int/campaignSupporter.int.spec.ts
Normal file
318
tests/int/campaignSupporter.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
71
tests/unit/supporterSchema.unit.spec.ts
Normal file
71
tests/unit/supporterSchema.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue