From b079daa9bb9481317db15a490689c664d4516d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sat, 18 Jul 2026 20:32:54 -0300 Subject: [PATCH] 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. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + docs/plans/cadastro-nominal-apoiadores.md | 141 +- docs/roadmap.md | 4 +- .../(app)/apoiadores/[id]/formActions.ts | 89 + .../(app)/apoiadores/[id]/not-found.tsx | 28 + .../campanha/(app)/apoiadores/[id]/page.tsx | 177 + .../(app)/apoiadores/importar/page.tsx | 32 + .../(app)/apoiadores/novo/formActions.ts | 115 + .../campanha/(app)/apoiadores/novo/page.tsx | 47 + .../campanha/(app)/apoiadores/page.tsx | 127 + .../(campaign)/campanha/actions/supporter.ts | 788 ++ src/collections/Supporter.ts | 228 + src/components/campaign/CampaignBottomNav.tsx | 10 +- .../campaign/RemoveSupporterDataButton.tsx | 52 + src/components/campaign/SupporterFilters.tsx | 229 + src/components/campaign/SupporterForm.tsx | 310 + .../campaign/SupporterImportWizard.tsx | 306 + src/components/campaign/SupporterList.tsx | 155 + .../campaign/SupporterListOverview.tsx | 31 + .../campaign/SupporterPagination.tsx | 61 + src/components/campaign/SupporterShareKit.tsx | 81 + .../campaign/VoteIntentionControl.tsx | 133 + src/components/campaign/nav.ts | 20 +- src/lib/schemas/supporter.ts | 107 + .../20260718_222656_add_supporter.json | 8125 +++++++++++++++++ .../20260718_222656_add_supporter.ts | 151 + src/migrations/index.ts | 8 +- src/payload-types.ts | 49 + src/payload.config.ts | 2 + src/utilities/campaignAccess.ts | 94 +- src/utilities/campaignConsent.ts | 70 +- src/utilities/supporterPageData.ts | 208 + src/utilities/supporterUi.ts | 185 + src/utilities/supporterViewModels.ts | 103 + tests/helpers/campaignFixtures.ts | 61 + tests/int/campaignSupporter.int.spec.ts | 318 + tests/unit/campaignConsent.unit.spec.ts | 32 +- tests/unit/campaignNucleusUi.unit.spec.ts | 14 +- tests/unit/supporterSchema.unit.spec.ts | 71 + 39 files changed, 12651 insertions(+), 112 deletions(-) create mode 100644 src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts create mode 100644 src/app/(campaign)/campanha/(app)/apoiadores/[id]/not-found.tsx create mode 100644 src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx create mode 100644 src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx create mode 100644 src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts create mode 100644 src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx create mode 100644 src/app/(campaign)/campanha/(app)/apoiadores/page.tsx create mode 100644 src/app/(campaign)/campanha/actions/supporter.ts create mode 100644 src/collections/Supporter.ts create mode 100644 src/components/campaign/RemoveSupporterDataButton.tsx create mode 100644 src/components/campaign/SupporterFilters.tsx create mode 100644 src/components/campaign/SupporterForm.tsx create mode 100644 src/components/campaign/SupporterImportWizard.tsx create mode 100644 src/components/campaign/SupporterList.tsx create mode 100644 src/components/campaign/SupporterListOverview.tsx create mode 100644 src/components/campaign/SupporterPagination.tsx create mode 100644 src/components/campaign/SupporterShareKit.tsx create mode 100644 src/components/campaign/VoteIntentionControl.tsx create mode 100644 src/lib/schemas/supporter.ts create mode 100644 src/migrations/20260718_222656_add_supporter.json create mode 100644 src/migrations/20260718_222656_add_supporter.ts create mode 100644 src/utilities/supporterPageData.ts create mode 100644 src/utilities/supporterUi.ts create mode 100644 src/utilities/supporterViewModels.ts create mode 100644 tests/int/campaignSupporter.int.spec.ts create mode 100644 tests/unit/supporterSchema.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index ec2356c8..0a3d351d 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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-` 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 diff --git a/docs/plans/cadastro-nominal-apoiadores.md b/docs/plans/cadastro-nominal-apoiadores.md index 37f6d0f6..b9e65095 100644 --- a/docs/plans/cadastro-nominal-apoiadores.md +++ b/docs/plans/cadastro-nominal-apoiadores.md @@ -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
(server action, geral)"] - Dedup["pg_advisory_xact_lock
hashtextextended(phone, 0)"] + Dedup["contactPhoneInvariant
advisory lock"] Contact["upsert Contact
(transacional)"] - Consent["resolve Consent por chave
apoiador-cadastro / apoiador-intencao-voto"] + Consent["resolve Consent por chave
campaignConsent.ts"] Supporter["create supporter
contact + nucleus opcional
+ voteIntention + consent"] - Agg["agregados por território/setor
+ insights (conversão, gap vs 2022)"] - Kit["kit de compartilhamento
wa.me + cards + textos"] - Share["apoiador repassa
individualmente (art. 33 §2º)"] - Unsub["unsubscribeSupporter
anonimiza/remove"] + Kit["kit mínimo
wa.me + texto"] + Share["apoiador repassa
individualmente"] + Unsub["removeSupporterData
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 diff --git a/docs/roadmap.md b/docs/roadmap.md index e53bf9f5..021171e9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue: 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 | diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts new file mode 100644 index 00000000..a27f1524 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts @@ -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 => { + 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 => { + 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.' } + } +} diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/not-found.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/not-found.tsx new file mode 100644 index 00000000..a4480560 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/not-found.tsx @@ -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 ( + + + Apoiador não encontrado + + O registro pode ter sido removido ou você não tem acesso a ele. + + + + + + + ) +} diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx new file mode 100644 index 00000000..278f18b8 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx @@ -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 ( +
+
+ + + {isCampaignGeneral(user) ? 'Coordenação geral' : 'Coordenação de núcleo'} + +

{supporter.name}

+

+ {supporter.city ?? 'Município não informado'} + {supporter.territory ? ` · ${supporter.territory}` : ''} +

+
+ + + + Resumo + + +

+ Cadastrado em {dateFormatter.format(new Date(supporter.createdAt))} + {supporter.createdByName ? ` · por ${supporter.createdByName}` : ''} +

+

Origem: {supporter.sourceLabel}

+ {voteIntentionLabel ? ( + + {voteIntentionLabel} + + ) : ( + + Sem intenção registrada + + )} +
+
+ + + + Dados de contato + + +

{supporter.phoneDisplay || '—'}

+ {supporter.email ?

{supporter.email}

: null} +

+ {supporter.nucleusName ? ( + <> + Núcleo:{' '} + + {supporter.nucleusName} + + + ) : ( + Sem núcleo vinculado + )} +

+
+
+ +
+
+
+
+ + + + + + +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx new file mode 100644 index 00000000..d32053d9 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx @@ -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 ( +
+
+ + Importação · Coordenação geral +

Importar apoiadores via CSV

+

+ Envie uma planilha, confira a prévia e confirme a importação em lote. +

+
+ + +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts new file mode 100644 index 00000000..3db2cda4 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts @@ -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 + 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 => { + 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) + } +} diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx new file mode 100644 index 00000000..a1bb34f2 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx @@ -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 ( +
+
+ + + {isCampaignGeneral(user) ? 'Coordenação geral' : 'Coordenação de núcleo'} + +

Novo apoiador

+

+ Cadastre um apoiador com telefone obrigatório e consentimento LGPD. +

+
+ + +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx new file mode 100644 index 00000000..d451a134 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx @@ -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> +} + +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 ( +
+
+
+

Apoiadores

+

+ Base nominal de apoio com intenção de voto e vínculo opcional a núcleos. +

+ + {isCampaignGeneral(user) + ? 'Coordenação geral · todos os apoiadores' + : getSupporterScopeLabel(result.totalDocs)} + +
+
+ {isCampaignGeneral(user) ? ( + + ) : null} + +
+
+ + + + {result.docs.length ? ( + <> + {overview ? : null} + toSupporterListItemViewModel(supporter))} + /> +
+

+ {result.totalDocs}{' '} + {result.totalDocs === 1 ? 'apoiador encontrado' : 'apoiadores encontrados'} +

+ +
+ + ) : ( + + + + + Nenhum apoiador encontrado + + Ajuste a busca ou os filtros. Você só vê apoiadores dentro do seu escopo. + + + + + + + )} +
+ ) +} diff --git a/src/app/(campaign)/campanha/actions/supporter.ts b/src/app/(campaign)/campanha/actions/supporter.ts new file mode 100644 index 00000000..dc0eead3 --- /dev/null +++ b/src/app/(campaign)/campanha/actions/supporter.ts @@ -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 + 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 => { + 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 => { + 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 = { + 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 = { + 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 => { + await getFreshGeneralActor(payload, actor) + await requireSupporterRegistrationConsent(payload) + await requireSupporterVoteIntentionConsent(payload) + + let records: Record[] + 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[] + } 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() + 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 => { + 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) +} diff --git a/src/collections/Supporter.ts b/src/collections/Supporter.ts new file mode 100644 index 00000000..da3a6081 --- /dev/null +++ b/src/collections/Supporter.ts @@ -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, + }, + }, + ], +} diff --git a/src/components/campaign/CampaignBottomNav.tsx b/src/components/campaign/CampaignBottomNav.tsx index c45f26cd..149fe19e 100644 --- a/src/components/campaign/CampaignBottomNav.tsx +++ b/src/components/campaign/CampaignBottomNav.tsx @@ -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 (