diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc new file mode 100644 index 00000000..ddfef5d4 --- /dev/null +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -0,0 +1,95 @@ +--- +description: Estado do projeto Núcleos Eleitorais +alwaysApply: false +--- + +# Núcleos Eleitorais + +## Status + +- Onda 1 (auth/RBAC e fundação visual): concluída e aprovada em 2026-07-16. +- Onda 2 (domínio Núcleo): implementada em 2026-07-16; aguardando integração/revisão. +- Onda 3B (domínio Liderança): implementada em 2026-07-16; migration aplicada nos bancos locais de desenvolvimento e teste. +- Onda 3A (UI Núcleo): concluída e aprovada em 2026-07-16. +- Onda 4A (UI Lideranças): implementada em 2026-07-16 com lista responsiva, filtros, ficha/formulário, contato principal e serialização específica por papel. +- Onda 4B (Inteligência do Núcleo): implementada em 2026-07-17 com fluxo seguro de sugerir/confirmar estimativas, edição de forças/riscos/perfis/dobrada/contato principal e DTOs mínimos por papel. +- Onda 6B (domínio de convites): implementada em 2026-07-17 com Consent por chave estável, convites WhatsApp com hash/expiração/revogação, consumo transacional de uso único, autopreenchimento restrito e criação/recuperação de acesso. +- Onda 6A (dashboard e atualizações): implementada e revisada em 2026-07-17 com agregados só de núcleos ativos, semana exata `America/Bahia`, feed paginado por URL e contato contextual da coordenação. +- Onda 7 (UI de convites): implementada em 2026-07-17 com ação WhatsApp por liderança, bloqueio por Consent/status e rota pública dinâmica de autopreenchimento/acesso/recuperação. +- Onda 8 (hardening): implementada em 2026-07-17 com E2E por papel, negativa de escopo, convite público/WhatsApp sem navegação real, verificação responsiva 360/390/768/1440, correção de alvos de toque no login, documentação e checklist de deploy. +- Em 2026-07-17, a listagem de Núcleos passou a aplicar filtros automaticamente pelo estado da URL, sem botão de confirmação, com comboboxes territoriais estritos compartilhados com o formulário. +- Em 2026-07-17, o contato principal passou a usar busca remota limitada e escopada no detalhe do núcleo, sem serializar a lista completa de lideranças. +- Em 2026-07-17, o feed paginado de atualizações e a prévia das três mais recentes passaram a usar loaders separados por aba, sem consulta de prévia fora da visão geral. +- Em 2026-07-17, o painel de lideranças passou a restaurar foco deterministicamente no fechamento de Sheet/Drawer e ganhou E2E real de URL, histórico, responsividade e privacidade de Flight. +- Em 2026-07-17, o login de campanha removeu React Hook Form/Zod do bundle cliente em favor de formulário nativo com `useActionState`; `Table` voltou a ser módulo estático sem fronteira cliente. +- Em 2026-07-17, os filtros da lista passaram a montar uma única árvore de controles em um disclosure responsivo; o estado canônico vindo do servidor remonta a ilha por chave de query, sem sincronização por `useSearchParams`. +- Em 2026-07-17, a composição Phase 1 separou o cartão estático de coordenação no servidor dos controles cliente; opções elegíveis e revisão do Núcleo agora são carregadas somente ao abrir o seletor. Editores pesados de inteligência, estimativa, atualização, liderança e convite passaram a fronteiras lazy locais. +- Em 2026-07-17, os shells lazy passaram a manter seus gatilhos Radix montados durante carregamento e edição, com restauração real de foco validada no navegador; o carregamento de coordenação permanece cancelável e só bloqueia fechamento durante a gravação. +- Em 2026-07-17, falhas nos chunks lazy de inteligência, atualização, convite e estimativa ganharam estado de erro acessível, fechamento seguro e nova tentativa controlada sem rejeição não tratada ou spinner infinito. +- Em 2026-07-17, o fluxo lazy de estimativa passou a transportar uma intenção explícita de foco através do refresh RSC: sugestão foca revisão, confirmação e edição focam o gatilho de editar, com fallback estável no cartão. +- Em 2026-07-17, a Refactor Phase 2C centralizou os advisory locks transacionais PostgreSQL e separou namespaces por domínio, com regressões concorrentes validadas. +- Em 2026-07-17, o follow-up Phase 2C tornou `contact-phone:` um invariante único para todo create/update de `Contact`; mudanças bloqueiam telefones antigo+novo em ordem determinística, rejeitam outro titular sem merge e preservam o convite em rollback. +- Em 2026-07-17, a Refactor Phase 2D reduziu `actions/invite.ts` a uma orquestração de 44 linhas e separou repositório SQL, criação e resgate em módulos `server-only`, preservando 488 testes de integração. +- Em 2026-07-17, a Refactor Phase 4A consolidou as dez migrations locais não publicadas de `20260717_*` em `20260718_010733_consolidate_campaign_schema`, mantendo intacta a história rastreada até `20260716_010420_add_campaign_user`; os bancos locais foram reconciliados sem alterar dados de aplicação. +- Em 2026-07-18, o follow-up Phase 4C fixou o Playwright em dois workers por padrão, adicionou guardas automáticos de erros do navegador/console/5xx, fortaleceu privacidade HTML+Flight por papel e tornou o login por teclado uma interação real com Enter e cookie verificado. +- Em 2026-07-18, a exceção concorrente de `SupportStatusBadge` foi rastreada a projeções em voo nas quais o acesso de campo podia omitir `supportStatus`; o agregado do dashboard agora valida o enum e omite registros incompletos sem inventar um status padrão. + +## Decisões + +- Núcleo Eleitoral é a unidade operacional; Zona TSE é uma referência oficial adicional. +- Todo núcleo exige geografia; natureza organizativa e setor são dimensões complementares. +- Exclusão no app é substituída por arquivamento. +- `lastUpdateAt` existe como placeholder até o domínio de atualizações. +- `Contact` tem e-mail opcional no banco, telefone normalizado/indexado e acesso por escopo; os schemas públicos continuam exigindo e-mail. +- `Leadership` é a junção única contato↔núcleo; acesso de liderança e contato exige vínculo `engajado`, e avaliação interna não é serializada para a titular. +- A UI de liderança usa view models separados: coordenação recebe avaliação interna; papel `lideranca` recebe somente a própria ficha sem `supportStatus`, `notes` ou `consentNote`. +- A deduplicação de `Contact` por telefone usa `pg_advisory_xact_lock(hashtextextended(phone, 0))` na própria sessão Drizzle da transação Payload, evitando duplicatas entre instâncias concorrentes. +- O DTO da própria liderança contém somente nome, celular, e-mail, setor e confirmação pela pessoa. +- “Convidar pelo WhatsApp” cria convite de uso único e abre `wa.me` no navegador; os E2E substituem `window.open` e validam a URL sem navegar para o WhatsApp. +- Sugestões de estimativa não alteram o valor confirmado; confirmação exige coordenação geral ou coordenador designado, deriva autoria/data no servidor e exige justificativa quando ajusta a proposta. +- Sugestão e confirmação da estimativa usam o mesmo `pg_advisory_xact_lock` por núcleo na sessão exata da transação Payload, serializando concorrência entre instâncias. +- Em 2026-07-17, cada sugestão de estimativa passou a receber uma versão UUID derivada no servidor; confirmações vinculam e comparam essa versão sob o mesmo lock, impedindo confirmações ABA obsoletas mesmo quando o valor volta a ser igual. +- Em 2026-07-17, links de convite passaram a exigir `NEXT_PUBLIC_SITE_URL` HTTPS e não local em produção; desenvolvimento/teste preferem a origem HTTP real da requisição local, sem confiar em `Host`/`X-Forwarded-*` sobre a configuração de produção. +- Em 2026-07-17, a origem de convites foi endurecida: produção ignora headers e exige `NEXT_PUBLIC_SITE_URL` como origem HTTPS exata (sem credenciais, path, query ou fragmento); desenvolvimento/teste só aceita headers para `localhost`, `127.0.0.1` ou `::1` exatos e usa a configuração explícita como único fallback. +- O detalhe do núcleo usa selects/DTOs discriminados por papel: liderança recebe apenas identificação, território básico, Zonas TSE e estimativa confirmada; inteligência estratégica e metadados de proposta/auditoria ficam restritos ao staff. +- Componentes React novos da Onda 4B usam arquivo PascalCase idêntico ao export principal; a renomeação dos componentes legados fica para a varredura separada. +- O consentimento de autopreenchimento é resolvido exclusivamente pela chave `lideranca-autopreenchimento`; ausência bloqueia criação e resgate sem fallback por ID. +- Convites persistem somente SHA-256 do token aleatório de 32 bytes, expiram em sete dias e revogam o convite ativo anterior do mesmo tipo/vínculo. +- O consumo usa `UPDATE ... WHERE` transacional com estado ativo e expiração, garantindo uso único mesmo sob corrida; falhas posteriores fazem rollback e preservam o convite. +- Autopreenchimento altera somente contato público, setor e consentimento; login exige vínculo engajado e só recupera uma conta existente quando `leadership.user` já aponta exatamente para ela. Coincidência de username sem vínculo é rejeitada, e uma conta nova sempre nasce como `lideranca`. +- `campaignUser.phone` é o celular opcional de contato; `username` permanece exclusivamente identidade de autenticação e nunca alimenta DTOs de contato. Leitura do telefone exige admin, titular, geral ou vínculo contextual por núcleo acessível. +- A ficha de liderança recebe somente `{ configured: boolean }` sobre o Consent; login fica indisponível fora de `engajado`, enquanto Consent ausente desabilita todos os convites com estado explícito. +- A rota pública `/campanha/convite/[token]` consulta exclusivamente o hash e serializa nome/celular/e-mail/gênero/setor/texto de Consent; estados inexistente/usado/expirado são indistinguíveis, sem avaliação interna. +- A rota pública força renderização dinâmica e no-store, usa `noindex,nofollow,nocache` e `referrer: no-referrer`; o resgate de login define o cookie isolado existente e redireciona apenas para `/campanha`. +- O Consent aceito só é preservado sem novo checkbox quando seu ID coincide com o documento atualmente configurado pela chave estável; versão diferente exige aceite explícito antes da troca, e rejeições fazem rollback sem substituir a relação anterior. +- O token bruto não é persistido nem enviado em campos de perfil. A verificação E2E mostrou que o Next.js serializa o segmento dinâmico e o argumento ligado da Server Action no HTML/Flight da própria página; a fronteira de segurança comprovada é `no-store`, `noindex`, `no-referrer`, nenhum request de terceiro e links externos com `noreferrer`. O token continua inevitavelmente presente na URL/documento same-origin. +- Campos opcionais self-service distinguem ausente (`undefined`, mantém valor) de vazio explícito (`null`, limpa valor), e o texto Lexical do Consent usa o renderer JSX oficial do Payload para preservar estrutura sem HTML injetado. +- O login de campanha garante alvos de toque mínimos de 44 px para identificador, senha e submit, com foco visível e ordem de teclado testada. +- A validação do login permanece autoritativa no servidor por `campaignLoginSchema`; o cliente usa apenas constraints nativas, metadados de autofill e estado pendente/erro da Server Action. +- Em 2026-07-17, toda senha recebida por `FormData` passou por uma fronteira central que rejeita ausência, vazio e `File`, mas preserva exatamente espaços e demais caracteres; apenas identificadores continuam normalizados. +- O shell mantém o documento sem overflow horizontal e o conteúdo em scroll interno nas larguras 360, 390, 768 e 1440 px. +- Em 2026-07-17, os caminhos internos foram renomeados para inglês: route group `(campaign)` e componentes em `src/components/campaign`; o segmento real `campanha` foi preservado, portanto as URLs continuam `/campanha`. +- Em 2026-07-17, Bahia passou a ser implícita no Núcleo. O território usa `region`, `city`, `neighborhood`, `locality` e `territoryNotes`; `state`, `territoryKind`, coordenadas e raio foram removidos. Região/cidade devem seguir a composição oficial vigente dos Territórios de Identidade. +- Em 2026-07-17, a composição oficial dos 417 municípios foi incorporada a partir da lista municipal da SECULT Bahia, validada pelos 27 códigos/contagens do mapa SEI 2024 (versão DPA 20/12/2024) e pela cobertura exata de `CitiesByState.BA`. O arquivo documenta as URLs oficiais; o formulário sincroniza município/território e o servidor rejeita pares incompatíveis. +- Em 2026-07-17, Núcleos receberam `slug` canônico único e as rotas passaram a `/campanha/nucleos/[slug]`. Para manter links duráveis sem uma tabela de aliases desproporcional neste ciclo, o nome ficou imutável após a criação; IDs permanecem internos nas relações e ações. +- Em 2026-07-17, responsáveis por Núcleo passaram a aceitar `campaignUser.role = geral|coordenador`; `lideranca` é rejeitada também em escritas diretas da Local API. Nos formulários, o usuário geral atual aparece primeiro com o rótulo visual “(você)”, sem alterar o nome persistido. +- Os filtros de Núcleos usam `router.replace`, reiniciam a paginação, preservam os demais parâmetros e sincronizam novamente a partir da URL em navegação voltar/avançar; a consulta Payload no servidor permanece a fonte dos resultados. +- A composição dos filtros usa uma única instância dos comboboxes oficiais. Atualizações rápidas derivam a próxima URL do estado controlado local, e a navegação canônica do servidor remonta a ilha pelo resultado de `buildNucleusFiltersKey`. +- O seletor de contato principal fixa o contato atual separadamente e retorna no máximo 99 alternativas quando ele existe (100 itens renderizados no total); a busca autenticada resolve o slug uma vez, restringe ao núcleo acessível e consulta somente lideranças engajadas. +- As opções de responsáveis nunca fazem parte do DTO da página do Núcleo: uma Server Action exclusiva de `geral` retorna apenas `{ id, name, isCurrent }` e `expectedUpdatedAt` ao abrir o diálogo; respostas obsoletas após fechamento/reabertura são ignoradas no cliente e a gravação continua protegida pela revisão e pelo lock existentes. +- Os diálogos lazy são controlados pelo shell que contém o gatilho persistente. Modos de estimativa mantêm um Root por gatilho para que Escape, overlay e X retornem ao botão exato; após sucesso com revalidação RSC, o modo de origem é transportado até a árvore atualizada para restaurar o foco. +- O carregamento dos módulos lazy compartilha somente uma pequena máquina de estados `idle|loading|ready|error`; cada shell conserva seu `import()` dinâmico e, após rejeição, fechar ou tentar novamente invalida a tentativa anterior para permitir um loader novo. +- O foco pós-sucesso da estimativa é condicionado aos dados atualizados (`proposedVoteEstimate` ou revisão de confirmação), usa refs dos gatilhos remontados e nunca timeout/RAF; quando o gatilho esperado não existe, o título focável do cartão recebe o foco. +- Advisory locks de runtime usam a sessão exata da transação Payload via `postgresTransactionLocks.ts`, com chaves textuais deduplicadas/ordenadas e namespaces independentes (`vote-estimate`, `nucleus-updates`, `primary-contact`, convites, telefone de contato e designação de coordenação). Leases de Consent permanecem exclusivamente em helpers de teste. +- A fronteira pública de convites exporta somente as três Server Actions; helpers privilegiados de SQL, autorização, token, consentimento e recuperação ficam em módulos `server-only`, com limites arquiteturais cobertos por teste. +- Em 2026-07-17, `Contact.city` passou a opcional porque Núcleos podem representar um Território de Identidade sem município/localidade; os fluxos públicos continuam exigindo cidade em seus próprios schemas. O cadastro de liderança mascara o celular no cliente, normaliza 11 dígitos no servidor e preserva valores/erros após falhas. +- Em 2026-07-17, decisão de produto: `Município` e `Bairro` do Núcleo passam a aceitar múltiplas entradas (arrays planos `cities`/`neighborhoods`), e `region` vira `regions` derivado no servidor a partir dos municípios, permitindo cruzar Territórios de Identidade. Bairros exigem município único; filtros da lista mudam de `equals` para `contains`. Modelo A (arrays planos) escolhido em vez de array estruturado por município. Plano: [`docs/plans/territorio-multi-municipio-bairro.md`](../../../docs/plans/territorio-multi-municipio-bairro.md); item do roadmap: "Território multi-município e multi-bairro". +- O DTO paginado de atualizações contém somente filtro/paginação/feed; a visão geral chama um loader dedicado que busca `limit: 3` e apenas os autores necessários à prévia. +- A lista paginada de lideranças seleciona e serializa somente os campos da linha; avaliação interna e dados completos de contato são carregados apenas para a liderança selecionada. O fechamento usa `onCloseAutoFocus` no Sheet e `finalFocus` no Drawer, focaliza a linha de origem (ou o título do Núcleo) antes de navegar para a URL canônica, sem RAF/timeout. +- Fonte: `/Users/francisco.solla/.cursor/plans/núcleos_eleitorais_mvp_7f788d29.plan.md`. + +## Bloqueadores e próximas frentes + +- **Bloqueador obrigatório de produção:** antes de dados reais ou convites, a assessoria jurídica eleitoral deve documentar a base do art. 11 da LGPD, aprovar o texto versionado específico e cadastrar `Consent.key = 'lideranca-autopreenchimento'`. Em 2026-07-17 esse texto entrou num **lote jurídico único** com os demais consents da campanha (`apoiador-cadastro`, `apoiador-intencao-voto`, `campanha-notificacoes-push`) e o Aviso de Privacidade — ver "Onda 0" do roadmap. +- Em 2026-07-17 o roadmap foi repriorizado orientado ao calendário eleitoral (Res. TSE 23.760/2026: propaganda a partir de 16/08, 1º turno 04/10), com ordem de execução, trilhas paralelas (A dados eleitorais/território, B superfícies de coordenação, C operação de campo, D plataforma) e grafo de dependências. Decisões de sequenciamento: lançar o MVP antes da migração multi-município; multi-município antes de zonas-por-municipio/eventos/demandas; baseline TSE independente de zonas-por-municipio (dependência rebaixada a suave); WhatsApp Business API movido a fora de escopo (vedação Meta + Res. 23.610 art. 33). +- Backlog consolidado do produto (admin RBAC, mapa, eventos, demandas, apoiadores, listas globais, site, white-label): [`docs/roadmap.md`](../../../docs/roadmap.md). diff --git a/.env.example b/.env.example index ee717268..34d113da 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,12 @@ DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo PAYLOAD_SECRET=YOUR_SECRET_HERE BLOB_READ_WRITE_TOKEN=YOUR_BLOB_TOKEN_HERE +# Canonical public origin used in absolute campaign invite links. In production +# this must be an exact HTTPS non-local origin: no credentials, path other than +# `/`, query, or fragment. Production ignores request headers and invite +# creation fails closed when this value is invalid. Development/test accepts +# request-derived origins only for exact localhost, 127.0.0.1, or ::1 hosts and +# normalizes them to HTTP; otherwise this configured origin is the only fallback. NEXT_PUBLIC_SITE_URL=http://localhost:3000 # Shared secret guarding POST /api/revalidate, which busts the `posts` cache tag diff --git a/.gitignore b/.gitignore index b763e2b6..df0d8dfd 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,5 @@ node_modules/ /playwright-report/ /blob-report/ /playwright/.cache/ +/.playwright-cli/ .aider* diff --git a/AGENTS.md b/AGENTS.md index 3fb8a358..0b68e65c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si **Locked decisions — do not relitigate without a new concrete reason:** -- Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campanha)` internal campaign tool (roles: assessores). No separate Rust service, no separate frontend app for `/campanha` — model it as Payload collections (it already has native `point` fields + `near` queries for the political map). The `(campanha)` route group now EXISTS with an auth barrier — it is NOT built on `users` + roles but on a **separate Payload auth collection** `campaignUser` (see "Campaign auth" below). +- Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `geral`, `coordenador`, and `lideranca`, all on the separate auth collection `campaignUser` (see "Campaign auth" below). - Hosting stays on Vercel for now. No self-host/Coolify migration in progress. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. @@ -32,8 +32,10 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si ## Known Gaps (as of 2026-07-15 — resolve before relying on this file for onboarding new devs) -1. **No `roles` field on either auth collection yet.** The RBAC examples below are generic Payload patterns, not yet implemented here — every `users` (admin) user today has full access, and the `/campanha` barrier is currently just "any authenticated `campaignUser` = full access" (see "Campaign auth" below). Roles are deferred until a `/campanha` screen actually needs to gate on them; add `roles` (e.g. `admin`/`editor`/`assessor`) to the relevant collection at that point (and before opening the admin to more people). -2. **`Consent` document IDs are hardcoded** in code (e.g. `consent: 2` in `submitWhatsapp.ts`). Fragile — breaks silently if that document is ever recreated with a different ID, and blocks having a reproducible local seed. Consider referencing by a stable slug/key instead. (The column-type drift is fixed; the hardcoded-ID fragility remains.) +Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.md`](docs/roadmap.md). + +1. **Payload admin RBAC is still absent.** The campaign auth collection now has `role`, but the `users` admin collection still has no roles; every admin user has full Payload admin access. Add admin roles before opening `/admin` to a broader team. +2. **Legacy public consent flows still hardcode document IDs** (e.g. `consent: 2` in `submitWhatsapp.ts`). The campaign leadership flow does not: it requires `Consent.key = 'lideranca-autopreenchimento'` and fails closed when missing. Migrating the older public flows remains separate work. 3. **`Post`/`Tag` ship the news system, but there is still no `Pages` collection for institutional content.** Partly resolved: the `post`/`tag` collections back the news/publications system, and the home "Últimas notícias" list plus the `/[type]`, `/[type]/[category]`, and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (`HomePage` global still exposes only a single `image` field — the text lives in `src/app/(frontend)/(home)/page.tsx`), and there is no `Pages` collection yet for institutional content such as the bio and propostas. **Recently resolved (2026-07-15):** local Postgres now runs via `docker-compose.yml` (Postgres 17, matching prod, auto-creating `teqo_test`); Payload migrations are set up with prod baselined; dev/test database guards prevent accidental production access (see the two database skills). The `post`/`tag` collections and the public news/article/listing routes shipped and are deployed (see "Posts & Tags" below). @@ -45,11 +47,11 @@ The public news/publications system is backed by two collections (both in the `P - **`post`** (`src/collections/Post.ts`) — fields: `title`, `slug` (unique, indexed, auto-slugified from `title` when left empty), `type` (select enum `noticia|campanha|artigo|evento`), `category` (required single relationship → `tag`), `tags` (`hasMany` relationship → `tag`), `subtitle`, `coverImage` (upload → `media`), `publishedDate`, `body` (richText). Has drafts/versions (`schedulePublish`, `maxPerDoc: 5`). - **`tag`** (`src/collections/Tag.ts`) — fields: `name`, `slug` (unique, indexed, auto-slugified from `name`), and `hidden` (checkbox, admin label "Esconder", default `false`). Tags are the shared taxonomy for both `post.category` and `post.tags`. -**Tags are a taxonomy, not a person record.** The `Contact` convention above applies to *people*; `tag` is publication metadata (categories + a visibility control flag), so post/tag relations do not touch `Contact`. Don't fold taxonomy into `Contact` or vice-versa. +**Tags are a taxonomy, not a person record.** The `Contact` convention above applies to _people_; `tag` is publication metadata (categories + a visibility control flag), so post/tag relations do not touch `Contact`. Don't fold taxonomy into `Contact` or vice-versa. **Electoral visibility control (`hidden` + fail-closed `isPostVisible`).** Marking a tag `hidden` hides every post that references it — as `category` or in `tags` — with a single toggle. This exists so campaign / pre-candidacy content (tagged `eleitoral`) can be pulled from the public site during the electoral period. `isPostVisible(post)` (`src/utilities/posts.ts`) returns true only when the post is `published` AND none of its related tags (`category` + `tags`) is `hidden`. It **fails closed**: an unpopulated relation (a bare numeric id) is treated as hiding, so callers must fetch with `depth >= 1`. Every public read filters through `getVisiblePosts()`, which applies this predicate to the cached published list. -**URLs and Portuguese SEO values.** The canonical article path is `/[type]/[category]/[slug]` (e.g. `/noticia/saude/`), with `/[type]` and `/[type]/[category]` listing routes and the home "Últimas notícias" list. The `type` enum values (`noticia|campanha|artigo|evento`) and tag/category slugs (`saude`, `eleitoral`, …) are deliberately Portuguese for SEO and are **data, not identifiers** — never translate them (see the naming-conventions bullet above). The route folders are `[type]/[category]/[slug]` (English param keys); the article route redirects any stale/mismatched URL to the canonical path derived from the post's *current* `type` + category slug. Article pages emit JSON-LD (`Article`) and Open Graph metadata. +**URLs and Portuguese SEO values.** The canonical article path is `/[type]/[category]/[slug]` (e.g. `/noticia/saude/`), with `/[type]` and `/[type]/[category]` listing routes and the home "Últimas notícias" list. The `type` enum values (`noticia|campanha|artigo|evento`) and tag/category slugs (`saude`, `eleitoral`, …) are deliberately Portuguese for SEO and are **data, not identifiers** — never translate them (see the naming-conventions bullet above). The route folders are `[type]/[category]/[slug]` (English param keys); the article route redirects any stale/mismatched URL to the canonical path derived from the post's _current_ `type` + category slug. Article pages emit JSON-LD (`Article`) and Open Graph metadata. **Caching (mixed ISR, `posts` tag).** Public reads go through the `unstable_cache` wrappers in `src/utilities/posts.ts` (`getCachedPublishedPosts`, `getCachedPostBySlug`), all tagged `posts`. The routes set `export const dynamicParams = true` and build `generateStaticParams` from `getVisiblePosts()`: known paths are statically generated, unknown ones render on demand, and everything stays cached until the `posts` tag is busted. `revalidatePostsListing()` (`src/utilities/documents.ts`) is `revalidateTag('posts')` (the listing tag is `` `${collection}s` `` → `posts`), so one call busts the home page, every listing route, and every article route at once. @@ -77,12 +79,33 @@ curl -X POST "https:///api/revalidate" \ The internal `/campanha` area is gated by its own authentication, deliberately kept **isolated from the Payload admin (`/admin`)** so a campaign session and an admin session can coexist in the same browser. -- **`campaignUser` collection** (`src/collections/CampaignUser.ts`) — a **separate Payload auth collection** (`auth: true`), labels "Usuário"/"Usuários", admin group "Campanha", with a single `name` field on top of the built-in auth fields. It is NOT `users`: `admin.user` in `payload.config.ts` stays `users`, so a `campaignUser` cannot log into `/admin` (it can still be managed there by an admin). This is why `/campanha` is not built on `users` + roles. +- **`campaignUser` collection** (`src/collections/CampaignUser.ts`) — a separate Payload auth collection with `name`, optional contact `phone`, and required `role`: `geral`, `coordenador`, or `lideranca` (default and least privilege). Staff may sign in by email; leadership accounts use the normalized 11-digit phone as `username`. It is not `users`: `admin.user` remains `users`, so campaign users cannot log into `/admin`. - **Isolated session cookie `campaign-token`.** The flow sets its own httpOnly cookie named `campaign-token` (constant `CAMPAIGN_TOKEN_COOKIE` in `src/utilities/campaignAuth.ts`), scoped to `path: '/campanha'`, `sameSite: 'lax'`, and `secure` only in production. It is deliberately NOT the default `payload-token` cookie — using a distinct name + path is what lets a campaign session and a Payload admin session live side by side without clobbering each other. -- **Session verification — `getCampaignUser()`** (`src/utilities/campaignAuth.ts`). Reads the `campaign-token` cookie, calls `payload.auth({ headers })` with an `Authorization: JWT ` header, and returns the user only when `user.collection === 'campaignUser'` (otherwise `null`) — so an admin `payload-token` can never satisfy the campaign barrier. -- **Login/logout server actions** (`src/app/(campanha)/campanha/actions/auth.ts`). `loginCampaign` validates input with `campaignLoginSchema` (`src/lib/schemas/campaign-login.ts`), calls `payload.login({ collection: 'campaignUser' })`, and — because the Local API returns a token WITHOUT setting a cookie — sets the `campaign-token` cookie itself (using the collection's `auth.tokenExpiration` as `maxAge`) before redirecting to `/campanha`. `logoutCampaign` clears the cookie (`maxAge: 0`) and redirects to `/campanha/login`. -- **Route layout.** The route group's root layout `src/app/(campanha)/layout.tsx` renders the ``/`` with `data-theme="campaign"`. The public login lives at `src/app/(campanha)/campanha/login/` (`page.tsx` + client `LoginForm.tsx`). Everything gated sits in the `(app)` group, whose layout (`src/app/(campanha)/campanha/(app)/layout.tsx`) calls `getCampaignUser()` and `redirect('/campanha/login')` when there is no session — that layout is the barrier. -- **Migration.** Adding the collection required migration `20260716_010420_add_campaign_user` (creates `campaign_user` + `campaign_user_sessions` tables and the related `payload_locked_documents_rels` / `payload_preferences_rels` columns). +- **Session verification — `getCampaignUser()`** (`src/utilities/campaignAuth.ts`). Reads the `campaign-token` cookie, calls `payload.auth({ headers })`, accepts only `user.collection === 'campaignUser'`, then reloads that document by ID. The fresh read makes role downgrades effective immediately instead of trusting a stale JWT role. +- **Login/logout server actions** (`src/app/(campaign)/campanha/actions/auth.ts`). `loginCampaign` validates input with `campaignLoginSchema` (`src/lib/schemas/campaign-login.ts`), calls `payload.login({ collection: 'campaignUser' })`, and — because the Local API returns a token WITHOUT setting a cookie — sets the `campaign-token` cookie itself (using the collection's `auth.tokenExpiration` as `maxAge`) before redirecting to `/campanha`. `logoutCampaign` clears the cookie (`maxAge: 0`) and redirects to `/campanha/login`. +- **Route layout.** The route group's root layout `src/app/(campaign)/layout.tsx` renders the ``/`` with `data-theme="campaign"`. The public login lives at `src/app/(campaign)/campanha/login/` (`page.tsx` + client `LoginForm.tsx`). Everything gated sits in the `(app)` group, whose layout (`src/app/(campaign)/campanha/(app)/layout.tsx`) calls `getCampaignUser()` and `redirect('/campanha/login')` when there is no session — that layout is the barrier. +- **Migrations.** `20260716_010420_add_campaign_user` created the auth collection; the unpublished 2026-07-17 campaign chain was consolidated into `20260718_010733_consolidate_campaign_schema`, which adds role/username auth, contact phone, and the remaining final campaign schema. + +## Campaign nuclei MVP + +The first `/campanha` operational cycle is centered on `electoralNucleus`; a Núcleo is the campaign's operating unit and is not a TSE electoral zone. + +- **Collections:** `electoralNucleus` (territory, coordinators, TSE zone references, intelligence and vote estimates), `leadership` (unique `Contact`↔nucleus join with support status), `nucleusUpdate` (immutable field reports), and `campaignInvite` (single-use WhatsApp invite hashes). All are in admin group `Campanha`. +- **Canonical nucleus URLs:** nuclei use a unique canonical `slug` generated from `name` and route through `/campanha/nucleos/[slug]`. IDs stay internal. Names are immutable after creation so existing shared links remain durable without an alias table. +- **Role scope:** `geral` sees/manages all nuclei and confirms estimates; `coordenador` sees assigned nuclei and manages their leaderships, updates, invites, and estimates; `lideranca` sees only nuclei linked through an engaged leadership record, creates own updates, and suggests estimates. +- **Sensitive fields:** `leadership.supportStatus`, internal notes, consent notes, and strategic nucleus intelligence are staff-only and are excluded from leadership view models. Revoking `engajado` revokes nucleus access immediately. +- **People and consent:** leaderships always reuse `Contact`; multi-collection writes are transactional. Self-service updates accept a strict whitelist derived from the invite token. Campaign consent is looked up only by the stable key `lideranca-autopreenchimento`. +- **Invites:** only SHA-256 token hashes are persisted. Public invite pages are dynamic, `no-store`, `noindex,nofollow`, `no-referrer`, and have no third-party application scripts. Login invites are allowed only for engaged leaderships. +- **Vote estimates:** proposals never overwrite the confirmed value; only a coordinator assigned to the nucleus or `geral` may confirm. + +### Local verification and deploy checklist + +1. Confirm `DATABASE_URL` is `postgresql://teqo:teqo@localhost:5432/teqo` and `.env.test` points to `teqo_test`; never use Neon for development, tests, E2E, or local builds. +2. Run `pnpm db:start`, `pnpm migrate:status`, and `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate:status`; every migration must show `Ran: Yes`. +3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed, `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm test:int`, `pnpm test:e2e`, and `pnpm build` against the local database. +4. Scan every edited runtime source file with Aikido and resolve new findings before handoff. Do not expand hardening work into unrelated pre-existing findings without an explicit scope change. +5. Review Vercel environment variables and migration SQL before deploy; `pnpm build` applies pending migrations to production automatically. +6. **Mandatory production blocker:** do not insert real leadership/support data or enable invites until electoral counsel documents the LGPD art. 11 basis, approves the specific versioned consent text, and an admin creates it with exactly `Consent.key = 'lideranca-autopreenchimento'`. The app intentionally fails closed when this key is absent. ## Core Principles @@ -108,7 +131,7 @@ src/ ├── app/ │ ├── (frontend)/ # Frontend routes + server actions (src/app/(frontend)/actions/*.ts) │ ├── (payload)/ # Payload admin routes -│ └── (campanha)/ # Internal campaign-management area (auth barrier, see "Campaign auth") +│ └── (campaign)/ # Internal campaign-management area (auth barrier, see "Campaign auth") ├── collections/ # Collection configs (Users, CampaignUser, Media, Petition, Signature, Consent, Contact, Subscription, Post, Tag) ├── globals/ # Global configs (SiteSettings, HomePage, Metadata) ├── components/ # Custom React components (incl. components/ui from shadcn) diff --git a/README.md b/README.md index 7ba67117..17a20fb9 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,6 @@ Then run `pnpm test` (or `pnpm test:int` / `pnpm test:e2e`). - PostgreSQL (via `@payloadcms/db-postgres`) - TypeScript -## Roadmap Notes +## Roadmap -- Prioritize audience ownership and portability of data. -- Keep modules generic enough for reuse across different political contexts. -- Default to secure, access-controlled features for campaign and institutional teams. +Backlog and future plans live in [`docs/roadmap.md`](docs/roadmap.md) (blockers, public site, `/campanha` next cycles, admin RBAC, white-label). diff --git a/docs/design-refs/campanha-nucleos/01-login.png b/docs/design-refs/campanha-nucleos/01-login.png new file mode 100644 index 00000000..99634996 Binary files /dev/null and b/docs/design-refs/campanha-nucleos/01-login.png differ diff --git a/docs/design-refs/campanha-nucleos/02-inicio-geral.png b/docs/design-refs/campanha-nucleos/02-inicio-geral.png new file mode 100644 index 00000000..57ab74bb Binary files /dev/null and b/docs/design-refs/campanha-nucleos/02-inicio-geral.png differ diff --git a/docs/design-refs/campanha-nucleos/03-inicio-coordenador.png b/docs/design-refs/campanha-nucleos/03-inicio-coordenador.png new file mode 100644 index 00000000..0179a083 Binary files /dev/null and b/docs/design-refs/campanha-nucleos/03-inicio-coordenador.png differ diff --git a/docs/design-refs/campanha-nucleos/04-lista-nucleos.png b/docs/design-refs/campanha-nucleos/04-lista-nucleos.png new file mode 100644 index 00000000..8f88a737 Binary files /dev/null and b/docs/design-refs/campanha-nucleos/04-lista-nucleos.png differ diff --git a/docs/design-refs/campanha-nucleos/05-detalhe-nucleo.png b/docs/design-refs/campanha-nucleos/05-detalhe-nucleo.png new file mode 100644 index 00000000..102839c1 Binary files /dev/null and b/docs/design-refs/campanha-nucleos/05-detalhe-nucleo.png differ diff --git a/docs/design-refs/campanha-nucleos/06-liderancas.png b/docs/design-refs/campanha-nucleos/06-liderancas.png new file mode 100644 index 00000000..472ae182 Binary files /dev/null and b/docs/design-refs/campanha-nucleos/06-liderancas.png differ diff --git a/docs/plans/baseline-eleitoral-tse.md b/docs/plans/baseline-eleitoral-tse.md new file mode 100644 index 00000000..2b3a0dd7 --- /dev/null +++ b/docs/plans/baseline-eleitoral-tse.md @@ -0,0 +1,183 @@ +# Baseline eleitoral TSE 2022 + +Status: rascunho +Atualizado em: 2026-07-17 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos") +Responsável: — + +## Contexto + +Os resultados das eleições passadas são **dados abertos públicos** do TSE (Portal de Dados Abertos, licença Creative Commons Atribuição), disponíveis por **município e zona eleitoral** (e por seção). Hoje o `/campanha` só tem a estimativa manual de votos (sugerir/confirmar); não há referência histórica. A decisão de produto (2026-07-17) é importar os resultados oficiais de 2022 (presidente, governador, dep. federal, dep. estadual) por município+zona e usá-los como **baseline histórico** em três lugares: no detalhe do núcleo, no overview da lista de núcleos (escopado pelos filtros), e em insights automáticos para coordenadores e lideranças. + +Conjunto TSE usado: ["Resultados - 2022"](https://dadosabertos.tse.jus.br/dataset/resultados-2022) — "Votação nominal por município e zona", "Detalhe da apuração por município e zona" e "Consulta candidatas e candidatos" (esta última traz `DS_SIT_TOT_TURNO`, que diz quem foi **eleito**). Arquivos por UF (BA) para governador/federal/estadual; BR para presidente. + +Candidatos de interesse (destaques na UI): **Solla = dep. federal BA, PT, nº 1313** (128.968 votos, eleito); **Lula = presidente, PT, nº 13**; **Jerônimo = governador BA, PT, nº 13** (venceu no 2º turno). + +## Objetivos + +- Importar o **conjunto completo de candidatos de 2022** para os quatro cargos (não só Solla/Lula/Jerônimo), com flag `elected`/`electedBy` por candidato. +- Armazenar no grain **município + zona** (casa com `electoralNucleus.city` + `electoralNucleus.tseZones[]`). +- Derivar, por núcleo, o baseline 2022: votos de Solla/Lula/Jerônimo, válidos, brancos, nulos, abstenções, aptos, mais votado local, ranking do Solla. +- Mostrar baseline no detalhe do núcleo (aba overview) e no overview da lista (reagindo aos filtros). +- Gerar insights automáticos (gap vs 2022, taxa de conversão, classificação territorial, alavancagem da chapa, oportunidade de mobilização, competidor). +- Deixar o modelo pronto para, quando o TSE publicar 2026, casar quem concorre de novo (`runningAgain2026`). + +## Decisões travadas + +- **Granularidade município + zona** (confirmada com o produto). Seção não tem mapeamento oficial → bairro e não cruzaria com o núcleo; volume proibitivo. +- **Todos os candidatos**, não só os de interesse. `electionCandidate` guarda 1 row por candidato com `elected`/`electedBy` derivados de `DS_SIT_TOT_TURNO`. +- **Três collections** em admin group `Dados Eleitorais`: `electionTally` (apuração por city+zone+office+turn), `electionCandidateVote` (votos por candidato por city+zone, só `votes > 0`), `electionCandidate` (registro de candidatura + eleito + identidade cross-ano). +- **Access control:** dado público TSE. `read` para autenticado em `campaignUser` ou `users` (não aberto ao público). `create/update/delete` admin only. Sem `Consent`, sem transação em runtime. Import via CLI com `overrideAccess: true` fora do runtime deployado. +- **Sem PII.** Não persistir CPF nem título eleitoral. A identidade cross-ano usa `identityKey = sha256(normalize(urnaName) + birthCity + birthState + party)` — só dados públicos da consulta de candidaturas. +- **Migration** `add_election_results` (push:false). `pnpm build` aplica em prod no deploy. Não esbarra no bloqueador LGPD (dado público, sem liderança real). +- **Import idempotente** pela chave única (upsert), com proveniência (URL, data, SHA-256) no cabeçalho do script. Sem step de revalidate (páginas de campanha são dinâmicas com auth, sem ISR/tag). +- **Agregação núcleo→baseline é autossuficiente** (revisão 2026-07-17): `citiesForTerritory` **já existe** em `src/lib/bahiaTerritories.ts` (não vem do plano `zonas-por-municipio.md`), e a correspondência cidade↔zona pode ser resolvida pelas próprias rows de `electionTally` (cada par cidade×zona importado é uma row — `where cityName = C` retorna todas as zonas da cidade). `tseZonesForCity` (plano `zonas-por-municipio.md`) é **opcional**: quando existir, vira a fonte preferida por ser validada por fixture. Sem `tseZones` → todas as zonas da(s) cidade(s); só `region` → todas as cidades do território; sem geografia → estado "sem baseline TSE". +- **i18n e naming** seguem o AGENTS.md: identificadores em inglês, strings visíveis em pt-BR. + +## Questões em aberto + +- Mapeamento `CD_MUNICIPIO` (TSE) → nome canônico em `CitiesByState.BA`: usar `NM_MUNICIPIO` do CSV + validação cruzada. +- Limiares de classificação territorial (defesa/ataque/indecisa/perdida) — definir com produto. +- Mostrar 1º e 2º turno para presidente/governador, ou só o decisivo? Recomendação: ambos, com destaque ao decisivo. +- `lideranca` vê ranking/competidor? Recomendação: sim, é dado público e útil ao engajamento. +- `identityKey` por nome de urna + naturalidade + partido pode gerar colisões (homônimos) ou falsos negativos (mudança de partido). Mitigação: revisão manual admin do match 2022→2026 antes de fixar `runningAgain2026=sim|nao`, com `desconhecido` como padrão. + +> Nota: limiares de classificação territorial, taxonomia de alinhamento político (para dobradinha) e limiares de conversão ficam nos planos separados dos respectivos insights no roadmap. + +## Abordagem proposta + +```mermaid +flowchart LR + TSE["Portal Dados Abertos TSE
CSVs 2022 (BA + BR)
resultados + candidaturas"] + Script["scripts/seed-tse-results.mjs
+ seed-loader.mjs
guard non-local DB"] + Coll1["electionTally
(year, office, turn, state, city, zone)
aptos, comparecimento, abstencoes,
validos, legenda, brancos, nulos,
winnerNumber, winnerName, winnerVotes"] + Coll2["electionCandidateVote
(year, office, turn, state, city, zone,
candidateNumber, name, coalition, party,
voteType: nominal|legenda, votes)
somente votes > 0"] + Coll3["electionCandidate
(year, office, turn, state,
candidateNumber, urnaName, party, coalition,
elected, electedBy, identityKey,
runningAgain2026?)
1 row por candidato"] + Pages["Detalhe do núcleo + Overview lista + Insights"] + TSE --> Script --> Coll1 + Script --> Coll2 + Script --> Coll3 + Coll1 --> Pages + Coll2 --> Pages + Coll3 --> Pages +``` + +### Collections + +- **`electionTally`** (`src/collections/ElectionTally.ts`): `year`, `office` (`presidente|governador|deputado_federal|deputado_estadual`), `turn` (1|2), `state` (`BA`), `cityCode`, `cityName`, `zoneNumber`, `aptos`, `comparecimento`, `abstencoes`, `votosValidos`, `votosNominaisValidos`, `votosLegenda`, `votosBranco`, `votosNulo`, `votosAnulados`, `winnerCandidateNumber`, `winnerCandidateName`, `winnerVotes`, `winnerCoalition`, `winnerParty`. Único `(year, office, turn, state, cityCode, zoneNumber)`. +- **`electionCandidateVote`** (`src/collections/ElectionCandidateVote.ts`): `year`, `office`, `turn`, `state`, `cityCode`, `cityName`, `zoneNumber`, `candidateNumber`, `candidateName`, `coalition`, `party`, `voteType` (`nominal|legenda`), `votes`. Único `(year, office, turn, state, cityCode, zoneNumber, candidateNumber, voteType)`. Só `votes > 0`. +- **`electionCandidate`** (`src/collections/ElectionCandidate.ts`): `year`, `office`, `turn`, `state`, `candidateNumber`, `urnaName`, `completeName`, `party`, `coalition`, `candidateStatus`, `elected` (checkbox), `electedBy` (`QP|média|2º turno`), `totalVotesState`, `identityKey` (index), `runningAgain2026` (`sim|nao|desconhecido`, default `desconhecido`). Único `(year, office, turn, state, candidateNumber)`. + +### Agregação núcleo → baseline + +```mermaid +flowchart LR + Nuc["electoralNucleus
cities[] + tseZones[]"] + Cities["cities efetivas:
nucleus.cities ?? citiesForTerritory(region)"] + PerCity["para cada cidade C:
zones = nucleus.tseZones ∩ tseZonesForCity(C)"] + Query["electionTally + electionCandidateVote
where city=C, zone in zones, office, turn"] + Sum["soma votos por candidato/tipo"] + Baseline["NucleusElectoralBaseline
solla/lula/jeronimo/validos/brancos/nulos/winner/rank"] + Nuc --> Cities --> PerCity --> Query --> Sum --> Baseline +``` + +### Fases + +1. **Modelo + import** — collections, migration, `scripts/seed-tse-results.mjs` + `pnpm db:seed:tse`, helpers `src/lib/electionResults.ts` e `src/lib/electionCandidateIdentity.ts`, teste int (totais conhecidos, `elected=true` para Solla/Lula/Jerônimo, idempotência). +2. **Detalhe do núcleo** — bloco "Baseline eleitoral 2022" na aba overview; view model `NucleusElectoralBaselineViewModel` em `src/utilities/nucleusViewModels.ts`; componente `src/components/campaign/NucleusElectoralBaseline.tsx`. +3. **Overview da lista** — estende `overview-lista-nucleos.md`: somar baselines sobre o conjunto filtrado (`buildNucleusListWhere`); novo bloco no `NucleusListOverview`. +4. **Insights automáticos (Gap vs 2022)** — implementa somente o insight "Gap vs 2022" (ver seção "Insights automáticos" abaixo). `src/components/campaign/NucleusInsights.tsx` + `src/lib/electionInsights.ts`. Os demais insights viram itens separados no roadmap com planos próprios. +5. **Prontidão 2026** — `identityKey` já em 2022; `scripts/reconcile-running-again.mjs` marca `runningAgain2026` quando 2026 publicar; UI sinaliza quem volta a concorrer. (O insight de dobradinha, que depende deste, é plano separado no roadmap.) + +## Insights automáticos + +Cada insight é uma **derivação de leitura** (sem escrita, sem `Consent`, sem migration) a partir do baseline TSE 2022 + `confirmedVoteEstimate`. Este plano implementa **apenas o insight "Gap vs 2022"** (Fase 4); os demais viram itens separados no roadmap, cada um com plano próprio, porque dependem de limiares/taxonomias a fechar com produto ou de dados de 2026. + +### 1. Gap vs 2022 — IN-SCOPE (Fase 4 deste plano) + +- **Definição:** compara a estimativa confirmada do núcleo com o voto histórico de Solla (dep. federal, nº 1313) em 2022 na mesma geografia. É o insight-cabeça que justifica o import do baseline. +- **Fórmula:** + - `sollaVotes2022` = soma de `electionCandidateVote.votes` onde `year=2022`, `office=deputado_federal`, `candidateNumber=1313`, `voteType=nominal`, `(city,zone)` ∈ geografia do núcleo (cidades∩zonas, ver agregação). + - `gap = confirmedVoteEstimate − sollaVotes2022`. + - `ratio = confirmedVoteEstimate / sollaVotes2022` (quando `sollaVotes2022 > 0`). +- **Inputs:** `electoralNucleus.confirmedVoteEstimate` + `electionCandidateVote` agregado pela geografia do núcleo. +- **Output no detalhe do núcleo:** linha/badge: + - `gap < 0` → "Faltam `{abs(gap)}` votos para o patamar de 2022" (cor de atenção). + - `gap ≥ 0` → "Já superamos 2022 em `{ratio−1}`%" (cor positiva). + - `sollaVotes2022 = 0` → "Solla não recebeu votos aqui em 2022 — território novo a abrir". + - `confirmedVoteEstimate = null` → "Sem estimativa confirmada para comparar". + - núcleo sem geografia → "Sem baseline TSE (informe território/município)". +- **Output no overview da lista:** sobre o conjunto filtrado: `gapTotal = Σ confirmedVoteEstimate − Σ sollaVotes2022`, `nucleiAbove2022` (contagem com `gap ≥ 0`), `nucleiBelow2022` (contagem com `gap < 0`). Renderizado no bloco "Baseline 2022" do `NucleusListOverview`. +- **Componente:** `src/components/campaign/NucleusInsights.tsx` (server; começa só com este insight; arquitetura extensível para os demais). +- **Helper:** `src/lib/electionInsights.ts` exportando `computeGapVs2022(baseline, confirmedVoteEstimate)` → `{ gap, ratio, status: 'above'|'below'|'noBaseline'|'noEstimate'|'noSolla2022' }`. +- **Visibilidade:** `geral`/`coordenador`/`lideranca` (dado público + estimativa já visível ao papel). +- **Teste int:** cenários com gap negativo, positivo, `sollaVotes2022=0`, `confirmedVoteEstimate=null` e sem geografia. + +### 2. Taxa de conversão — PLANO SEPARADO no roadmap + +- **Definição:** `confirmedVoteEstimate / aptos` — % do eleitorado apto da zona que a estimativa representa. +- **Inputs:** `electionTally.aptos` agregado pela geografia + `confirmedVoteEstimate`. +- **Limiares (guia de estratégia):** <15% oportunidade; >40% reduto. A definir com produto. +- **Roadmap:** "Insight: taxa de conversão por núcleo". + +### 3. Classificação territorial — PLANO SEPARADO no roadmap + +- **Definição:** classificar o núcleo em defesa/ataque/indecisa/perdida por `sollaPercentValid` (= `sollaVotes / votosValidosFederal`) vs. limiares configuráveis; opcionalmente cruzar com pesquisa de intenção de voto quando existir. +- **Inputs:** `electionCandidateVote` (Solla) + `electionTally.votosValidos` agregados. +- **Dependência externa:** limiares a definir com produto; possível sobreposição com futuro domínio de pesquisas. +- **Roadmap:** "Insight: classificação territorial (defesa/ataque/indecisa/perdida)". + +### 4. Alavancagem da chapa — PLANO SEPARADO no roadmap + +- **Definição:** `confirmedVoteEstimate / lulaVotes` e `/ jeronimoVotes` — % da base de Lula/Jerônimo capturada; o teto natural do PT no local como meta implícita. +- **Inputs:** `electionCandidateVote` para presidente (nº 13) e governador (nº 13) agregado + `confirmedVoteEstimate`. +- **Roadmap:** "Insight: alavancagem da chapa (Lula/Jerônimo)". + +### 5. Oportunidade de mobilização — PLANO SEPARADO no roadmap + +- **Definição:** `brancos + nulos` e `abstencoes` como potencial não aproveitado (votos que não foram a ninguém / eleitores que não compareceram). +- **Inputs:** `electionTally.votosBranco + votosNulo + abstencoes` agregados. +- **Roadmap:** "Insight: oportunidade de mobilização (brancos/nulos/abstenções)". + +### 6. Inteligência competitiva — PLANO SEPARADO no roadmap + +- **Definição:** `winnerFederal` (mais votado local para federal em 2022), margem sobre Solla, e `sollaRank` (posição do Solla entre os candidatos no local). +- **Inputs:** `electionCandidateVote` agregado por geografia, ordenado por votos; `electionCandidate.elected`. +- **Roadmap:** "Insight: inteligência competitiva (mais votado, ranking, margem)". + +### 7. Oportunidades de dobradinha 2026 — PLANO SEPARADO no roadmap + +- **Definição:** candidatos que concorrem de novo em 2026, priorizados por (a) alinhamento político com a chapa PT/Solla e (b) força eleitoral local (votos de 2022 na geografia). +- **Inputs:** `electionCandidate.runningAgain2026` + `electionCandidateVote` 2022 + `electionAlliances` (tiers de alinhamento). +- **Dependência:** Fase 5 (2026 carregado) + taxonomia de alinhamento (`src/lib/electionAlliances.ts`). +- **Roadmap:** "Insight: oportunidades de dobradinha 2026". + +## Arquivos a criar/alterar + +- Criar: `src/collections/ElectionTally.ts`, `src/collections/ElectionCandidateVote.ts`, `src/collections/ElectionCandidate.ts`, `src/lib/electionResults.ts`, `src/lib/electionInsights.ts`, `src/lib/electionCandidateIdentity.ts`, `src/utilities/nucleusElectoralBaseline.ts`, `src/components/campaign/NucleusElectoralBaseline.tsx`, `src/components/campaign/NucleusInsights.tsx`, `scripts/seed-tse-results.mjs`, `scripts/reconcile-running-again.mjs`, migration `add_election_results`, testes int. (Os demais componentes de insight — `electionAlliances.ts`, `DobradinhaOpportunities.tsx` etc. — ficam nos planos separados dos respectivos insights.) +- Alterar: `src/payload.config.ts`, `src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx` + `src/utilities/nucleusDetailPageData.ts`, `src/utilities/nucleusPageData.ts`/`nucleusViewModels.ts`, overview da lista, `package.json` (`db:seed:tse`). + +## Dependências + +1. `zonas-por-municipio.md` — **dependência suave, não bloqueante** (revisado em 2026-07-17). A agregação funciona sem ele: `citiesForTerritory` já existe em `bahiaTerritories.ts` e as zonas de cada cidade saem das próprias rows de `electionTally`. O que aquele plano melhora é a **qualidade do input**: com auto-preenchimento, `electoralNucleus.tseZones` deixa de depender de digitação manual (zonas erradas produzem baseline errado; zonas ausentes caem no fallback correto "todas as zonas da cidade"). Recomendado implementá-lo antes ou em paralelo, mas este plano pode ser executado de forma independente. +2. `territorio-multi-municipio-bairro.md` (torna `cities[]`) — `getNucleusElectoralBaseline` já desenhado para iterar sobre `cities[]`; funciona igualmente com o modelo single atual (array de 1). + +## Não escopo + +- Dados por seção eleitoral (grain escolhido: município+zona). +- Outras eleições além de 2022 (extensível via `year`; 2026 entra reusando o mesmo modelo quando o TSE publicar). +- Outros estados além da BA (todo núcleo é baiano; `state` fica `BA`). +- Previsão estatística de votos (roadmap linha 62) — aqui só baseline histórico + comparação com estimativa manual. +- Mapa/PostGIS (roadmap linha 52) — separado. +- Substitui o plano `zonas-por-municipio.md` — ele continua válido como melhoria de qualidade do input (dependência suave, ver "Dependências"). + +## Referências + +- `docs/roadmap.md` (seção "Campanha → Próximos ciclos") +- `docs/plans/zonas-por-municipio.md` — dependência suave (`tseZonesForCity` como fonte preferida de zonas por cidade; `citiesForTerritory` já existe em `bahiaTerritories.ts`) +- `docs/plans/overview-lista-nucleos.md` — Fase 3 estende este plano +- `src/collections/ElectoralNucleus.ts` — `city`, `region`, `tseZones` +- `src/utilities/nucleusViewModels.ts` — onde entra `NucleusElectoralBaselineViewModel` +- `scripts/seed-posts.mjs` + `scripts/seed-loader.mjs` — padrão de seed CLI com guard non-local +- Portal de Dados Abertos do TSE — https://dadosabertos.tse.jus.br/dataset/resultados-2022 +- AGENTS.md — naming conventions, padrão de leitura/escrita, "Bahia implícita no Núcleo" diff --git a/docs/plans/cadastro-nominal-apoiadores.md b/docs/plans/cadastro-nominal-apoiadores.md new file mode 100644 index 00000000..dcf3dd72 --- /dev/null +++ b/docs/plans/cadastro-nominal-apoiadores.md @@ -0,0 +1,154 @@ +# 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) +Responsável: — + +## Contexto + +Hoje a campanha só cadastra pessoas via `leadership` — a junção única `Contact`↔núcleo que carrega status de apoio, vínculo com `campaignUser` (acesso ao app) e consentimento. Não existe base nominal de apoiadores "comuns": eleitores que declaram apoio mas não são lideranças engajadas com acesso à plataforma. O roadmap pede explicitamente o cadastro nominal em massa via `Contact` + junção, **sem nunca criar uma collection "pessoa/apoiador" paralela** (decisão travada no AGENTS.md, espelhada por `Signature`/`Subscription`). + +Decisão de produto (2026-07-17): modelo **híbrido** — apoiador é join `Contact`↔campanha com **núcleo opcional**. A base é transversal (pode existir sem núcleo) e, quando vinculada a um núcleo, agrega por território. `leadership` permanece como o subconjunto que tem acesso ao app. A **pre-intenção de votos** (intenção de voto) entra neste plano: é dado sensível (opinião política) e exige consentimento destacado, mas é o ganho de produto central — transforma a estimativa manual de hoje em base nominal real, alimentando os insights de conversão e gap vs. 2022. + +## Marco legal (LGPD + TSE) + +Pesquisa web (2026-07-17). Fontes: TSE (Res. 23.610/2019 alterada pela 23.732/2024), ConJur, Migalhas, OAB Campinas, ANPD. + +- **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. +- **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. +- **Mensagens** devem trazer identificação completa do remetente e mecanismo de descadastramento/eliminação. + +## Objetivos + +- Nova collection `supporter` (join `Contact`↔campanha, núcleo opcional), no admin group `Campanha`. +- 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. +- Canal de descadastramento/eliminação (direito LGPD art. 18). +- Access control por papel herdando o padrão existente (`getAccessibleNucleusIds`). + +## Decisões travadas + +- **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`. + +## Questões em aberto + +- **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. + +## Abordagem proposta + +```mermaid +flowchart LR + Import["Import CSV / cadastro manual
(server action, geral)"] + Dedup["pg_advisory_xact_lock
hashtextextended(phone, 0)"] + Contact["upsert Contact
(transacional)"] + Consent["resolve Consent por chave
apoiador-cadastro / apoiador-intencao-voto"] + 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"] + + Import --> Dedup + Dedup --> Contact + Contact --> Consent + Consent --> Supporter + Supporter --> Agg + Supporter --> Kit + Kit --> Share + Supporter --> Unsub +``` + +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`). +- **`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`. + +## 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`). +- **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. + +## 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. + +## 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). +- 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. + +**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. + +## Referências + +- [`docs/roadmap.md`](../roadmap.md) (linha 56) +- [`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/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 diff --git a/docs/plans/compartilhar-pagina.md b/docs/plans/compartilhar-pagina.md new file mode 100644 index 00000000..20eabf30 --- /dev/null +++ b/docs/plans/compartilhar-pagina.md @@ -0,0 +1,104 @@ +# Compartilhar página do núcleo + +Status: rascunho +Atualizado em: 2026-07-17 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos", linha 51) +Responsável: — + +## Contexto + +Hoje o detalhe do núcleo (`/campanha/nucleos/[slug]`) só tem ações de escrita (Editar, Nova liderança, Nova atualização, Arquivar). Não existe um caminho rápido para um usuário da campanha mandar o link daquele núcleo a outra pessoa do mesmo núcleo. A decisão de produto (2026-07-17) é adicionar um botão **Compartilhar** que oferece quatro destinos: coordenação geral, coordenador, liderança ou copiar link. Para os três primeiros, abre o WhatsApp via `wa.me` com o telefone do destinatário — o mesmo padrão já usado pelos convites, **sem WhatsApp Business API**. + +Importante: compartilhar **não concede acesso**. O link aponta para `/campanha/nucleos/[slug]`, rota autenticada que já filtra por escopo (`canReadElectoralNucleus` em `src/utilities/campaignAccess.ts`). O destinatário só vê o núcleo se tiver sessão de campanha válida e o núcleo no seu escopo. O compartilhamento é só conveniência — envia o link, não cria convite, não gera token, não toca em `Consent`. + +## Objetivos + +- Botão de ação "Compartilhar" no header do detalhe do núcleo, ao lado das ações existentes. +- Quatro destinos: coordenação geral, coordenador, liderança, copiar link. +- Para os três primeiros, abrir `wa.me` com o telefone do destinatário e uma mensagem pronta (remetente + nome do núcleo + link). +- Reusar o padrão `wa.me` dos convites (`buildWhatsAppUrl` em `src/utilities/phone.ts` e `buildCampaignInviteWhatsAppLink` em `src/utilities/campaignInvite.ts` como referência de texto). +- Manter access control existente: nenhum dado sensível (telefone) é exposto a quem já não pudesse lê-lo. Sem server action de escrita, sem collection nova, sem `Consent`. + +## Decisões travadas + +- **Escopo: só o detalhe do núcleo.** O botão vive no header de `src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx`. A listagem de núcleos (`NucleusCard`) fica fora deste ciclo (ver "Não escopo"). +- **Compartilhar não é convite.** Não cria `campaignInvite`, não gera token, não expira, não revoga. Reusa o padrão `wa.me` apenas como canal (mesma decisão do notebook: "Convidar pelo WhatsApp" cria convite; aqui não). +- **Sem WhatsApp Business API.** Mesma justificativa do roadmap linha 57: o remetente envia pelo seu próprio WhatsApp, abrindo `wa.me` em nova aba com `noopener,noreferrer` (igual ao `LeadershipInviteDialog`). +- **Sem server action.** A URL `wa.me` é montada no cliente a partir do telefone do destinatário (já carregado no server) + mensagem com remetente/nome do núcleo/link. Nenhuma escrita, nenhuma transação, nenhum `req`. +- **Access control por `overrideAccess: false`.** Os telefones dos destinatários vêm de queries com `user` + `overrideAccess: false`, então o field access existente (`canReadCampaignUserPhone`, `canReadContacts`) filtra naturalmente quem pode ver qual telefone. O plano não inventa regra nova. +- **Link compartilhado = URL canônica do núcleo.** `${NEXT_PUBLIC_SITE_URL}/campanha/nucleos/${slug}` — reusa `getCampaignInviteBaseURL()` de `src/utilities/campaignInvite.ts` para derivar a origem (mesmo guard de produção/localhost). +- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`ShareNucleusDialog`, `getNucleusShareRecipients`), strings visíveis em pt-BR. +- **`lideranca` compartilha, mas sem "Com liderança".** `lideranca` vê "Com coordenação geral", "Com coordenador" e "Copiar link". A seção "Com liderança" fica oculta para esse papel: o escopo de `canReadLeadership` para `lideranca` é `user = self`, então só retornaria a própria liderança, o que é inútil. +- **`lideranca` lendo telefone de coordenador/geral.** `canReadCampaignUserPhone` já aprova leitura quando o alvo é coordenador de um núcleo acessível ao usuário, e `lideranca` tem núcleos acessíveis via liderança engajada. Mantém-se a política atual do app; se produto quiser bloquear, é mudar `canReadCampaignUserPhone`, não este plano. +- **`geral` é destinatário sim.** Além de coordenador e liderança, a coordenação geral (`campaignUser.role = 'geral'`) também é destinatária — própria seção "Com coordenação geral", listando todos os usuários `geral` (name+telefone, `overrideAccess: false`). Qualquer `geral` é destinatário válido de qualquer núcleo porque vê todos. +- **Loader dedicado.** `getNucleusShareRecipients` seleciona só nome+telefone de `geral`, coordenadores e lideranças do núcleo, `overrideAccess: false`, sem paginação (volume por núcleo é baixo). Evita acoplar ao estado de filtro/paginação da aba de lideranças e evita vazar telefone de quem o ator não pode ver. +- **Destinatário sem telefone é ocultado.** Sem telefone não há `wa.me`; o destinatário simplesmente não aparece na lista (em vez de aparecer desabilitado). +- **Núcleo arquivado também compartilha.** O link funciona para quem tem acesso; o botão permanece visível mesmo com `status = 'arquivado'`. +- **UI: Dialog.** Dialog central (como `LeadershipInviteDialog`), reusando `src/components/ui/dialog` e o padrão visual do `LeadershipInviteDialog` — dá espaço para listar destinatários em mobile. +- **Texto da mensagem (pt-BR).** Template fixo: `Oi {destinatário}, aqui é {remetente} da campanha do Solla. Veja o núcleo {nome}: {link}`. + +## Abordagem proposta + +```mermaid +flowchart LR + Page["NucleusDetailPage (server)
getNucleusShareRecipients
overrideAccess:false"] + Header["Header de ações
botão Compartilhar"] + Dialog["ShareNucleusDialog (client)
4 destinos"] + Geral["Com coordenação geral
lista {name,phone}
buildWhatsAppUrl"] + Coord["Com coordenador
lista {name,phone}
buildWhatsAppUrl"] + Lider["Com liderança
lista {name,phone}
buildWhatsAppUrl"] + Copy["Copiar link
navigator.clipboard"] + Wa["wa.me
window.open noopener,noreferrer"] + Recipient["Destinatário
abre /campanha/nucleos/slug
auth + escopo existente"] + + Page --> Header + Header --> Dialog + Dialog --> Geral + Dialog --> Coord + Dialog --> Lider + Dialog --> Copy + Geral --> Wa + Coord --> Wa + Lider --> Wa + Wa --> Recipient +``` + +Componentes: + +- **`getNucleusShareRecipients`** (em `src/utilities/nucleusShareRecipients.ts`): recebe `payload`, `user`, `nucleusSlug`. Carrega o núcleo por slug (`overrideAccess: false`, `depth: 0`, `select: { coordinators: true }`) para confirmar acesso e obter IDs dos coordenadores. Depois: + - Coordenação geral: `payload.find({ collection: 'campaignUser', where: { role: { equals: 'geral' } }, select: { name: true, phone: true }, overrideAccess: false, user })`. + - Coordenadores: `payload.find({ collection: 'campaignUser', where: { and: [{ id: { in: coordinatorIds } }, { role: { equals: 'coordenador' } }] }, select: { name: true, phone: true }, overrideAccess: false, user })` (filtra `coordenador` para não duplicar `geral` já listado; `geral` eventualmente presente em `coordinators` aparece só na seção de coordenação geral). + - Lideranças: `payload.find({ collection: 'leadership', where: { nucleus: { equals: nucleusId } }, depth: 1, select: { contact: true }, overrideAccess: false, user })` → extrai `{ id, name, phone }` do `contact` populado. + - Retorna `{ general: Recipient[], coordinators: Recipient[], leaderships: Recipient[], nucleusUrl }`, onde `Recipient = { id, name, phone }`. Telefones que o ator não pode ler chegam como `null`/ausente e são filtrados pelo componente. Para `lideranca`, a query de lideranças já retorna só a própria (escopo do access), e o componente oculta a seção. +- **`ShareNucleusDialog`** (client, em `src/components/campaign/`): botão "Compartilhar" (`ShareIcon`) que abre `Dialog`. Dentro, quatro seções: + - **Com coordenação geral** — lista de usuários `geral` com botão "Abrir WhatsApp" cada. + - **Com coordenador** — lista de coordenadores do núcleo. Oculta quando vazia. + - **Com liderança** — lista de lideranças do núcleo. Oculta para `lideranca` (e quando vazia). + - **Copiar link** — `navigator.clipboard.writeText(nucleusUrl)` com estado "copiado" (reusar o padrão de `copied` do `LeadershipInviteDialog`). + - Cada ação de WhatsApp monta `buildWhatsAppUrl(phone, message)` com `message = "Oi {name}, aqui é {senderName} da campanha do Solla. Veja o núcleo {nucleusName}: {nucleusUrl}"` e abre com `window.open(url, '_blank', 'noopener,noreferrer')`. + - Reusa ``/``/` + + + + ) +} diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx new file mode 100644 index 00000000..0ed58918 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/layout.tsx @@ -0,0 +1,44 @@ +import { redirect } from 'next/navigation' +import React from 'react' + +import { CampaignBottomNav } from '@/components/campaign/CampaignBottomNav' +import { CampaignSidebar } from '@/components/campaign/CampaignSidebar' +import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/Sidebar' +import { Toaster } from '@/components/ui/Toaster' +import { getCampaignUser } from '@/utilities/campaignAuth' + +export default async function CampaignAppLayout({ children }: { children: React.ReactNode }) { + const user = await getCampaignUser() + + if (!user) { + redirect('/campanha/login') + } + + return ( + + + +
+ +
+ Jorge Solla + + Campanha · Bahia + +
+
+
+ {children} +
+ + +
+
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/loading.tsx b/src/app/(campaign)/campanha/(app)/loading.tsx new file mode 100644 index 00000000..c6f2dbee --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/loading.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function CampaignDashboardLoading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 5 }, (_, index) => ( + + ))} +
+ +
+ {Array.from({ length: 3 }, (_, index) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions.ts new file mode 100644 index 00000000..0818f528 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions.ts @@ -0,0 +1,93 @@ +'use server' + +import config from '@payload-config' +import { getPayload } from 'payload' +import { revalidatePath } from 'next/cache' +import { ZodError } from 'zod' + +import { assignNucleusCoordinators } from '@/app/(campaign)/campanha/actions/coordinatorAssignment' +import { + FormDataBoundaryError, + repeatedRelationshipFormValues, + validationFieldErrors, +} from '@/lib/formData' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions' + +export type CoordinatorAssignmentFormState = { + status?: 'success' + message?: string + fieldErrors?: Record +} + +export type CoordinatorAssignmentOptionsResult = { + expectedUpdatedAt: string + options: Array<{ id: number; name: string; isCurrent: boolean }> +} + +const knownMessages = new Set([ + 'Somente a coordenação geral pode alterar coordenadores.', + 'Núcleo não encontrado ou sem acesso.', + 'A coordenação deste núcleo foi alterada. Atualize a página e tente novamente.', + 'Uma ou mais pessoas selecionadas não são mais elegíveis para coordenação.', +]) + +const errorState = (error: unknown): CoordinatorAssignmentFormState => { + if (error instanceof FormDataBoundaryError) { + return { fieldErrors: { [error.field]: [error.message] } } + } + if (error instanceof ZodError) return { fieldErrors: validationFieldErrors(error) } + if (error instanceof Error && knownMessages.has(error.message)) { + return { message: error.message } + } + return { message: 'Não foi possível salvar a coordenação. Atualize a página e tente novamente.' } +} + +export const assignNucleusCoordinatorsFormAction = async ( + slug: string, + _state: CoordinatorAssignmentFormState, + formData: FormData, +): Promise => { + try { + const expectedUpdatedAt = formData.get('expectedUpdatedAt') + if (typeof expectedUpdatedAt !== 'string' || !expectedUpdatedAt) { + return { message: 'A coordenação deste núcleo foi alterada. Atualize a página e tente novamente.' } + } + await assignNucleusCoordinators({ + slug, + expectedUpdatedAt, + coordinatorIds: repeatedRelationshipFormValues(formData, 'coordinators'), + }) + revalidatePath(`/campanha/nucleos/${slug}`) + revalidatePath('/campanha') + return { status: 'success', message: 'Coordenação atualizada.' } + } catch (error) { + return errorState(error) + } +} + +export const loadCoordinatorAssignmentOptions = async ( + slug: string, +): Promise => { + const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()]) + if (!user || user.role !== 'geral') { + throw new Error('Somente a coordenação geral pode alterar coordenadores.') + } + + const result = await payload.find({ + collection: 'electoralNucleus', + where: { slug: { equals: slug } }, + depth: 0, + limit: 1, + select: { updatedAt: true }, + user, + overrideAccess: false, + }) + const nucleus = result.docs[0] + if (!nucleus) throw new Error('Núcleo não encontrado ou sem acesso.') + + return { + expectedUpdatedAt: nucleus.updatedAt, + options: await getEligibleNucleusCoordinatorOptions(payload, user), + } +} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/editar/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/editar/page.tsx new file mode 100644 index 00000000..16edbcc6 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/editar/page.tsx @@ -0,0 +1,49 @@ +import config from '@payload-config' +import { getPayload } from 'payload' +import { notFound, redirect } from 'next/navigation' + +import { updateNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions' +import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' +import { NucleusForm } from '@/components/campaign/NucleusForm' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { getNucleusEditPageData } from '@/utilities/nucleusPageData' + +type EditNucleusPageProps = { + params: Promise<{ slug: string }> +} + +export default async function EditNucleusPage({ params }: EditNucleusPageProps) { + const [{ slug }, user, payload] = await Promise.all([ + params, + getCampaignUser(), + getPayload({ config }), + ]) + + if (!user) redirect('/campanha/login') + if (user.role === 'lideranca') redirect('/campanha/nucleos') + + if (!slug) notFound() + const view = await getNucleusEditPageData(payload, user, slug).catch(() => notFound()) + + return ( +
+
+ + {user.role === 'geral' + ? 'Edição · Coordenação geral' + : 'Edição · Núcleo sob sua coordenação'} + +

Núcleos Eleitorais

+

Editar {view.name}

+

+ Atualize o território sem alterar o nome ou as estimativas confirmadas. +

+
+ +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/leadershipFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/leadershipFormActions.ts new file mode 100644 index 00000000..1cca5c12 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/leadershipFormActions.ts @@ -0,0 +1,164 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { ZodError } from 'zod' + +import { + createLeadership, + updateLeadershipInternal, +} from '@/app/(campaign)/campanha/actions/leadership' +import { updateNucleus } from '@/app/(campaign)/campanha/actions/nucleus' +import { leadershipInternalUpdateSchema } from '@/lib/schemas/leadership' +import { + FormDataBoundaryError, + nullableFormText, + optionalFormText, + requiredRelationshipFormValue, + validationFieldErrors, +} from '@/lib/formData' + +export type LeadershipFormState = { + status?: 'success' + message?: string + fieldErrors?: Record + leadershipId?: number + contactReused?: boolean + values?: LeadershipFormValues + revision?: number +} + +export type LeadershipFormValues = { + name?: string + phone?: string + email?: string + gender?: string + sector?: string + sectorNotes?: string + supportStatus?: string + notes?: string + consentNote?: string +} + +const safeActionMessages = [ + 'Esta pessoa já está cadastrada como liderança neste núcleo.', + 'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.', + 'Escolha outro contato principal antes de alterar o status desta liderança.', + 'Somente a coordenação pode gerenciar lideranças.', +] as const + +const getLeadershipFormError = ( + error: unknown, + values?: LeadershipFormValues, + revision?: number, +): LeadershipFormState => { + if (error instanceof FormDataBoundaryError) { + const message = + error.field === 'contact' + ? 'Não foi possível identificar a liderança. Atualize a página e tente novamente.' + : error.field === 'id' || error.field === 'nucleus' + ? 'Não foi possível identificar o registro. Atualize a página e tente novamente.' + : error.message + return { fieldErrors: { [error.field]: [message] }, values, revision } + } + if (error instanceof ZodError) { + return { fieldErrors: validationFieldErrors(error), values, revision } + } + if ( + error instanceof Error && + safeActionMessages.includes(error.message as (typeof safeActionMessages)[number]) + ) { + return { message: error.message, values, revision } + } + return { + message: 'Não foi possível salvar a liderança. Verifique os dados e tente novamente.', + values, + revision, + } +} + +export const createLeadershipFormAction = async ( + state: LeadershipFormState, + formData: FormData, +): Promise => { + let values: LeadershipFormValues | undefined + const revision = (state.revision ?? 0) + 1 + + try { + values = { + name: optionalFormText(formData, 'name'), + phone: optionalFormText(formData, 'phone'), + email: optionalFormText(formData, 'email'), + gender: optionalFormText(formData, 'gender'), + sector: optionalFormText(formData, 'sector'), + sectorNotes: optionalFormText(formData, 'sectorNotes'), + supportStatus: optionalFormText(formData, 'supportStatus'), + notes: optionalFormText(formData, 'notes'), + consentNote: optionalFormText(formData, 'consentNote'), + } + const input = { + nucleus: requiredRelationshipFormValue(formData, 'nucleus'), + ...values, + name: values.name ?? '', + phone: values.phone ?? '', + } + const leadership = await createLeadership(input) + revalidatePath('/campanha/nucleos/[slug]', 'page') + + return { + status: 'success', + message: leadership.contactReused + ? 'Liderança cadastrada. O contato existente com este celular foi reutilizado.' + : 'Liderança cadastrada com sucesso.', + leadershipId: leadership.id, + contactReused: leadership.contactReused, + } + } catch (error) { + return getLeadershipFormError(error, values, revision) + } +} + +export const updateLeadershipFormAction = async ( + _state: LeadershipFormState, + formData: FormData, +): Promise => { + try { + const input = leadershipInternalUpdateSchema.parse({ + id: requiredRelationshipFormValue(formData, 'id'), + sector: nullableFormText(formData, 'sector'), + sectorNotes: nullableFormText(formData, 'sectorNotes'), + supportStatus: optionalFormText(formData, 'supportStatus'), + notes: nullableFormText(formData, 'notes'), + consentNote: nullableFormText(formData, 'consentNote'), + }) + const leadership = await updateLeadershipInternal(input) + revalidatePath('/campanha/nucleos/[slug]', 'page') + + return { + status: 'success', + message: 'Liderança atualizada com sucesso.', + leadershipId: leadership.id, + } + } catch (error) { + return getLeadershipFormError(error) + } +} + +export const setPrimaryContactFormAction = async ( + _state: LeadershipFormState, + formData: FormData, +): Promise => { + try { + await updateNucleus({ + id: requiredRelationshipFormValue(formData, 'nucleus'), + primaryContact: requiredRelationshipFormValue(formData, 'contact'), + }) + revalidatePath('/campanha/nucleos/[slug]', 'page') + + return { + status: 'success', + message: 'Contato principal atualizado.', + } + } catch (error) { + return getLeadershipFormError(error) + } +} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/not-found.tsx b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/not-found.tsx new file mode 100644 index 00000000..c4b02bd4 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/not-found.tsx @@ -0,0 +1,33 @@ +import Link from 'next/link' +import { MapPinOffIcon } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/Empty' + +export default function NucleusNotFound() { + return ( + + + + + Núcleo não encontrado + + Ele pode não existir ou estar fora do seu escopo de acesso. + + + + + + + ) +} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions.ts new file mode 100644 index 00000000..59dd3f89 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions.ts @@ -0,0 +1,34 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { ZodError } from 'zod' + +import { updateNucleus } from '@/app/(campaign)/campanha/actions/nucleus' +import { FormDataBoundaryError, validationFieldErrors } from '@/lib/formData' +import { parseNucleusIntelligenceFormData } from '@/utilities/nucleusIntelligenceUi' + +export type NucleusIntelligenceFormState = { + status?: 'success' + message?: string + fieldErrors?: Record +} + +export const updateNucleusIntelligenceFormAction = async ( + _state: NucleusIntelligenceFormState, + formData: FormData, +): Promise => { + try { + const input = parseNucleusIntelligenceFormData(formData) + await updateNucleus(input) + revalidatePath('/campanha/nucleos/[slug]', 'page') + return { status: 'success', message: 'Inteligência do núcleo atualizada.' } + } catch (error) { + if (error instanceof FormDataBoundaryError) { + return { fieldErrors: { [error.field]: [error.message] } } + } + if (error instanceof ZodError) return { fieldErrors: validationFieldErrors(error) } + return { + message: 'Não foi possível atualizar a inteligência. Verifique os dados e tente novamente.', + } + } +} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions.ts new file mode 100644 index 00000000..a891445c --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions.ts @@ -0,0 +1,68 @@ +'use server' + +import config from '@payload-config' +import { getPayload, type Payload } from 'payload' +import { revalidatePath } from 'next/cache' +import { ZodError } from 'zod' + +import { createNucleusUpdateRecord } from '@/app/(campaign)/campanha/actions/nucleusUpdate' +import type { CampaignUser } from '@/payload-types' +import { FormDataBoundaryError, validationFieldErrors } from '@/lib/formData' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { parseNucleusUpdateFormData } from '@/utilities/nucleusUpdateUi' +import { requireRelationshipId } from '@/utilities/relationship' + +export type NucleusUpdateFormState = { + status?: 'success' + message?: string + fieldErrors?: Record +} + +export const createNucleusUpdateFormRecordAction = async ( + payload: Payload, + actor: CampaignUser, + formData: FormData, +): Promise => { + try { + const input = parseNucleusUpdateFormData(formData) + const created = await createNucleusUpdateRecord(payload, actor, input) + return { + status: 'success', + message: 'Atualização enviada com sucesso.', + nucleusId: requireRelationshipId(created.nucleus), + } + } catch (error) { + if (error instanceof FormDataBoundaryError) { + return { fieldErrors: { [error.field]: [error.message] } } + } + if (error instanceof ZodError) { + return { fieldErrors: validationFieldErrors(error) } + } + return { + message: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.', + } + } +} + +export const createNucleusUpdateFormAction = async ( + _state: NucleusUpdateFormState, + formData: FormData, +): Promise => { + const [payload, actor] = await Promise.all([getPayload({ config }), getCampaignUser()]) + if (!actor) { + return { + message: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.', + } + } + + const { nucleusId, ...state } = await createNucleusUpdateFormRecordAction( + payload, + actor, + formData, + ) + if (state.status === 'success' && nucleusId) { + revalidatePath('/campanha') + revalidatePath('/campanha/nucleos/[slug]', 'page') + } + return state +} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx new file mode 100644 index 00000000..d3d2ce30 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx @@ -0,0 +1,213 @@ +import config from '@payload-config' +import { PencilIcon, PlusIcon } from 'lucide-react' +import Link from 'next/link' +import { notFound, redirect } from 'next/navigation' +import { getPayload } from 'payload' +import { Suspense } from 'react' + +import { + assignNucleusCoordinatorsFormAction, + loadCoordinatorAssignmentOptions, +} from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions' +import { confirmVoteEstimateFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions' +import { ArchiveNucleusDialog } from '@/components/campaign/ArchiveNucleusDialog' +import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' +import { CoordinatorAssignmentCard } from '@/components/campaign/CoordinatorAssignmentCard' +import { CoordinatorAssignmentDialog } from '@/components/campaign/CoordinatorAssignmentDialog' +import { NucleusActiveTab, NucleusActiveTabLoading } from '@/components/campaign/NucleusActiveTab' +import { NucleusTabNav } from '@/components/campaign/NucleusTabNav' +import { TseZoneBadge } from '@/components/campaign/TseZoneBadge' +import { VoteEstimateCard } from '@/components/campaign/VoteEstimateCard' +import { VoteEstimateDialogShell } from '@/components/campaign/VoteEstimateDialogShell' +import { Badge } from '@/components/ui/Badge' +import { Button } from '@/components/ui/button' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { + buildLeadershipPanelHref, + nucleusDetailFocusFallbackId, + parseLeadershipFilterState, +} from '@/utilities/leadershipUi' +import { loadNucleusDetailPageData } from '@/utilities/nucleusDetailPageData' +import { + getNucleusDetailTabRedirect, + resolveNucleusDetailTab, +} from '@/utilities/nucleusDetailTabUi' +import { NucleusNotFoundError } from '@/utilities/nucleusPageData' +import { organizationKindLabels } from '@/utilities/nucleusUi' + +type NucleusDetailPageProps = { + params: Promise<{ slug: string }> + searchParams: Promise> +} + +const detailScopeLabels = { + geral: 'Visível para você · Coordenação geral', + coordenador: 'Visível para você · Coordenador', + lideranca: 'Visível para você · Liderança', +} as const + +export default async function NucleusDetailPage({ params, searchParams }: NucleusDetailPageProps) { + const [{ slug }, query, user, payload] = await Promise.all([ + params, + searchParams, + getCampaignUser(), + getPayload({ config }), + ]) + + if (!user) redirect('/campanha/login') + + if (!slug) notFound() + const nucleusKind = user.role === 'lideranca' ? 'leadership' : 'staff' + const activeTab = resolveNucleusDetailTab(query, nucleusKind) + const { context, view, coordinatorAssignment } = await loadNucleusDetailPageData( + payload, + user, + slug, + activeTab, + ).catch((error) => { + if (error instanceof NucleusNotFoundError) notFound() + throw error + }) + const canonicalTabRedirect = getNucleusDetailTabRedirect(view.slug, query, view.kind) + if (canonicalTabRedirect) redirect(canonicalTabRedirect) + + const leadershipFilters = parseLeadershipFilterState(query) + const canEdit = user.role === 'geral' || user.role === 'coordenador' + const isStaffView = view.kind === 'staff' + const confirmVoteEstimateAction = confirmVoteEstimateFormAction.bind( + null, + isStaffView ? view.proposedVoteEstimateVersion : null, + ) + const territory = [view.neighborhood, view.locality, view.city, view.region] + .filter(Boolean) + .join(' · ') + const voteEstimateFocusTargetId = `vote-estimate-${view.id}-focus-target` + + return ( +
+
+ {detailScopeLabels[user.role]} +
+
+
+

+ {view.name} +

+ {view.status === 'arquivado' ? Arquivado : null} +
+ + {view.organizationLabel ?? organizationKindLabels[view.organizationKind]} + {' · '} + {territory} + +
+ {view.tseZones.length ? ( + view.tseZones.map((zoneNumber) => ( + + )) + ) : ( + Sem Zona TSE + )} +
+
+
+ {canEdit ? ( + + ) : null} + {user.role !== 'lideranca' ? ( + + ) : null} + + {user.role === 'geral' && view.status === 'ativo' ? ( + + ) : null} +
+
+
+ +
+ + {coordinatorAssignment.canManage ? ( + + ) : null} + + + + } + /> +
+ + + }> + + +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/primaryContactSearchActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/primaryContactSearchActions.ts new file mode 100644 index 00000000..fdc2e0da --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/primaryContactSearchActions.ts @@ -0,0 +1,29 @@ +'use server' + +import config from '@payload-config' +import { getPayload } from 'payload' + +import { getCampaignUser } from '@/utilities/campaignAuth' +import { resolveAccessibleNucleusContext } from '@/utilities/nucleusPageData' +import { + searchNucleusPrimaryContactOptions, + type NucleusPrimaryContactPageData, +} from '@/utilities/primaryContactPageData' + +export const searchPrimaryContactOptionsFormAction = async ( + nucleusSlug: string, + query: string, +): Promise => { + const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()]) + if (!user || user.role === 'lideranca') { + throw new Error('Somente a coordenação pode buscar contatos principais.') + } + + const context = await resolveAccessibleNucleusContext(payload, user, nucleusSlug) + return searchNucleusPrimaryContactOptions( + payload, + user, + context, + typeof query === 'string' ? query : '', + ) +} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions.ts new file mode 100644 index 00000000..87cda2a5 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions.ts @@ -0,0 +1,80 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { ZodError } from 'zod' + +import { + confirmVoteEstimate, + suggestVoteEstimate, +} from '@/app/(campaign)/campanha/actions/voteEstimate' +import { + FormDataBoundaryError, + optionalFormText, + requiredIntegerFormValue, + requiredRelationshipFormValue, + validationFieldErrors, +} from '@/lib/formData' + +export type VoteEstimateFormState = { + status?: 'success' + message?: string + fieldErrors?: Record +} + +const errorState = (error: unknown): VoteEstimateFormState => { + if (error instanceof FormDataBoundaryError) { + return { fieldErrors: { [error.field]: [error.message] } } + } + if (error instanceof ZodError) return { fieldErrors: validationFieldErrors(error) } + if ( + error instanceof Error && + [ + 'A liderança precisa de vínculo engajado com este núcleo.', + 'Somente a coordenação pode confirmar estimativas.', + 'A sugestão foi alterada. Atualize a página antes de confirmar.', + 'Informe uma justificativa para ajustar a estimativa.', + ].includes(error.message) + ) { + return { message: error.message } + } + return { message: 'Não foi possível salvar a estimativa. Tente novamente.' } +} + +export const suggestVoteEstimateFormAction = async ( + _state: VoteEstimateFormState, + formData: FormData, +): Promise => { + try { + const updated = await suggestVoteEstimate({ + nucleus: requiredRelationshipFormValue(formData, 'nucleus'), + estimate: requiredIntegerFormValue(formData, 'estimate', { + maximum: 100_000_000, + }), + }) + revalidatePath(`/campanha/nucleos/${updated.slug}`) + return { status: 'success', message: 'Sugestão enviada para revisão.' } + } catch (error) { + return errorState(error) + } +} + +export const confirmVoteEstimateFormAction = async ( + expectedProposedVoteEstimateVersion: string | null, + _state: VoteEstimateFormState, + formData: FormData, +): Promise => { + try { + const updated = await confirmVoteEstimate({ + nucleus: requiredRelationshipFormValue(formData, 'nucleus'), + estimate: requiredIntegerFormValue(formData, 'estimate', { + maximum: 100_000_000, + }), + expectedProposedVoteEstimateVersion, + confirmationNote: optionalFormText(formData, 'confirmationNote'), + }) + revalidatePath(`/campanha/nucleos/${updated.slug}`) + return { status: 'success', message: 'Estimativa confirmada.' } + } catch (error) { + return errorState(error) + } +} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/formActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/formActions.ts new file mode 100644 index 00000000..743dd03b --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/nucleos/formActions.ts @@ -0,0 +1,121 @@ +'use server' + +import config from '@payload-config' +import { getPayload } from 'payload' +import { redirect } from 'next/navigation' +import { ZodError } from 'zod' + +import { + archiveNucleus, + createNucleus, + updateNucleus, +} from '@/app/(campaign)/campanha/actions/nucleus' +import type { NucleusFormState } from '@/components/campaign/NucleusForm' +import { + FormDataBoundaryError, + optionalFormText, + requiredRelationshipFormValue, + validationFieldErrors, +} from '@/lib/formData' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { + parseNucleusCreateFormData, + parseNucleusUpdateFormData, +} from '@/utilities/nucleusFormData' +import { slugify } from '@/utilities/slug' + +const getActionError = (error: unknown): NucleusFormState => { + if (error instanceof FormDataBoundaryError) { + return { fieldErrors: { [error.field]: [error.message] } } + } + if (error instanceof ZodError) { + return { fieldErrors: validationFieldErrors(error) } + } + + return { message: 'Não foi possível concluir a ação. Atualize a página e tente novamente.' } +} + +const existingNucleusState = async (formData: FormData): Promise => { + const name = optionalFormText(formData, 'name') + const slug = name ? slugify(name) : '' + if (!slug) return null + const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()]) + if (!user || user.role !== 'geral') return null + const result = await payload.find({ + collection: 'electoralNucleus', + where: { slug: { equals: slug } }, + depth: 0, + limit: 1, + pagination: false, + select: { slug: true }, + user, + overrideAccess: false, + }) + const existing = result.docs[0] + return existing + ? { + fieldErrors: { name: ['Já existe um núcleo com este nome.'] }, + existingHref: `/campanha/nucleos/${existing.slug}`, + submittedName: name, + } + : null +} + +export const createNucleusFormAction = async ( + _state: NucleusFormState, + formData: FormData, +): Promise => { + let nucleusSlug: string + + try { + const nucleus = await createNucleus(parseNucleusCreateFormData(formData)) + nucleusSlug = nucleus.slug + } catch (error) { + const existing = await existingNucleusState(formData) + if (existing) return existing + return getActionError(error) + } + + redirect(`/campanha/nucleos/${nucleusSlug}`) +} + +export const updateNucleusFormAction = async ( + _state: NucleusFormState, + formData: FormData, +): Promise => { + let nucleusSlug: string + + try { + const input = parseNucleusUpdateFormData(formData) + const nucleus = await updateNucleus(input) + nucleusSlug = nucleus.slug + } catch (error) { + return getActionError(error) + } + + redirect(`/campanha/nucleos/${nucleusSlug}`) +} + +export const archiveNucleusFormAction = async ( + _state: NucleusFormState, + formData: FormData, +): Promise => { + let nucleusId: number + try { + nucleusId = requiredRelationshipFormValue(formData, 'id') + } catch { + return { + message: 'Não foi possível identificar o núcleo. Atualize a página e tente novamente.', + } + } + + try { + await archiveNucleus(nucleusId) + } catch { + return { + message: + 'Não foi possível arquivar este núcleo. Verifique seu acesso ou atualize a página e tente novamente.', + } + } + redirect('/campanha/nucleos') +} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/loading.tsx b/src/app/(campaign)/campanha/(app)/nucleos/loading.tsx new file mode 100644 index 00000000..79f1d635 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/nucleos/loading.tsx @@ -0,0 +1,18 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function NucleiLoading() { + return ( +
+
+ + +
+ + +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/novo/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/novo/page.tsx new file mode 100644 index 00000000..5148a323 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/nucleos/novo/page.tsx @@ -0,0 +1,36 @@ +import config from '@payload-config' +import { getPayload } from 'payload' +import { redirect } from 'next/navigation' + +import { createNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions' +import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' +import { NucleusForm } from '@/components/campaign/NucleusForm' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions' + +export default async function NewNucleusPage() { + const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) + + if (!user) redirect('/campanha/login') + if (user.role !== 'geral') redirect('/campanha/nucleos') + + const coordinators = await getEligibleNucleusCoordinatorOptions(payload, user) + + return ( +
+
+ Criação · Coordenação geral +

Núcleos Eleitorais

+

Novo núcleo

+

+ Defina a operação, o território obrigatório e quem responde por ele. +

+
+ +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/page.tsx new file mode 100644 index 00000000..47c9619f --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/nucleos/page.tsx @@ -0,0 +1,108 @@ +import type { ElectoralNucleus } from '@/payload-types' +import config from '@payload-config' +import { PlusIcon, SearchXIcon } from 'lucide-react' +import Link from 'next/link' +import { redirect } from 'next/navigation' +import { getPayload } from 'payload' + +import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' +import { NucleusFilters } from '@/components/campaign/NucleusFilters' +import { NucleusList } from '@/components/campaign/NucleusList' +import { NucleusPagination } from '@/components/campaign/NucleusPagination' +import { Button } from '@/components/ui/button' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/Empty' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { loadNucleusListPageData } from '@/utilities/nucleusPageData' +import { + buildNucleusFiltersKey, + getCampaignScopeLabel, + resolveNucleusListUrl, +} from '@/utilities/nucleusUi' +import { toNucleusListViewModel } from '@/utilities/nucleusViewModels' + +type NucleiPageProps = { + searchParams: Promise> +} + +export default async function NucleiPage({ searchParams }: NucleiPageProps) { + const rawSearchParams = await searchParams + const canonicalUrl = resolveNucleusListUrl(rawSearchParams) + if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref) + + const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) + + if (!user) return null + + const { result, scope } = await loadNucleusListPageData(payload, user, rawSearchParams) + const resolvedUrl = resolveNucleusListUrl(rawSearchParams, result.totalPages) + + if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref) + const { state } = resolvedUrl + + return ( +
+
+
+

Núcleos Eleitorais

+

+ Núcleo é a operação da campanha; Zona TSE é a circunscrição oficial. +

+ + {getCampaignScopeLabel(user.role, scope.totalDocs)} + +
+ {user.role === 'geral' ? ( + + ) : null} +
+ + + + {result.docs.length ? ( + <> + + toNucleusListViewModel(nucleus as ElectoralNucleus), + )} + /> +
+

+ {result.totalDocs}{' '} + {result.totalDocs === 1 ? 'núcleo encontrado' : 'núcleos encontrados'} +

+ +
+ + ) : ( + + + + + Nenhum núcleo encontrado + + Ajuste a busca ou os filtros. Você só vê núcleos dentro do seu escopo. + + + + + + + )} +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/page.tsx b/src/app/(campaign)/campanha/(app)/page.tsx new file mode 100644 index 00000000..f41741b2 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/page.tsx @@ -0,0 +1,18 @@ +import config from '@payload-config' +import { getPayload } from 'payload' +import { redirect } from 'next/navigation' + +import { CampaignDashboard } from '@/components/campaign/CampaignDashboard' +import { getCampaignDashboardPageData } from '@/utilities/campaignDashboardPageData' +import { getCampaignUser } from '@/utilities/campaignAuth' + +export const dynamic = 'force-dynamic' + +export default async function CampaignHomePage() { + const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()]) + if (!user) redirect('/campanha/login') + + const now = new Date() + const view = await getCampaignDashboardPageData(payload, user, now) + return +} diff --git a/src/app/(campanha)/campanha/actions/auth.ts b/src/app/(campaign)/campanha/actions/auth.ts similarity index 51% rename from src/app/(campanha)/campanha/actions/auth.ts rename to src/app/(campaign)/campanha/actions/auth.ts index dab3bc6b..ff06cbc4 100644 --- a/src/app/(campanha)/campanha/actions/auth.ts +++ b/src/app/(campaign)/campanha/actions/auth.ts @@ -5,13 +5,15 @@ import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { getPayload } from 'payload' +import { requiredFormSecret, requiredFormText } from '@/lib/formData' import { campaignLoginSchema, type CampaignLoginInput } from '@/lib/schemas/campaign-login' -import { CAMPAIGN_TOKEN_COOKIE } from '@/utilities/campaignAuth' +import { + CAMPAIGN_COOKIE_PATH, + CAMPAIGN_TOKEN_COOKIE, + setCampaignAuthCookie, +} from '@/utilities/campaignAuth' -const COOKIE_PATH = '/campanha' -const DEFAULT_TOKEN_EXPIRATION = 7200 - -export type LoginResult = { error: string } +export type LoginResult = { error?: string } export const loginCampaign = async (input: CampaignLoginInput): Promise => { const parsed = campaignLoginSchema.safeParse(input) @@ -25,36 +27,44 @@ export const loginCampaign = async (input: CampaignLoginInput): Promise => { + try { + return loginCampaign({ + identifier: requiredFormText(formData, 'identifier'), + password: requiredFormSecret(formData, 'password'), + }) + } catch { + return { error: 'Dados inválidos.' } + } +} + export const logoutCampaign = async (): Promise => { const cookieStore = await cookies() @@ -62,7 +72,7 @@ export const logoutCampaign = async (): Promise => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - path: COOKIE_PATH, + path: CAMPAIGN_COOKIE_PATH, maxAge: 0, }) diff --git a/src/app/(campaign)/campanha/actions/coordinatorAssignment.ts b/src/app/(campaign)/campanha/actions/coordinatorAssignment.ts new file mode 100644 index 00000000..e15c22ea --- /dev/null +++ b/src/app/(campaign)/campanha/actions/coordinatorAssignment.ts @@ -0,0 +1,110 @@ +'use server' + +import type { Payload } from 'payload' +import { z } from 'zod' + +import type { CampaignUser } from '@/payload-types' +import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' +import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorOptions' +import { acquireNucleusRowLocks } from '@/utilities/nucleusRowLocks' +import { withPayloadTransaction } from '@/utilities/payloadTransaction' +import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' + +const coordinatorAssignmentSchema = z.object({ + slug: z.string().trim().min(1).max(200), + coordinatorIds: z + .array(z.number().int().positive()) + .max(100) + .transform((ids) => [...new Set(ids)]), + expectedUpdatedAt: z.iso.datetime(), +}) + +export type CoordinatorAssignmentInput = z.infer + +export const assignNucleusCoordinatorsRecord = async ( + payload: Payload, + actor: CampaignUser, + input: CoordinatorAssignmentInput, +) => { + const data = coordinatorAssignmentSchema.parse(input) + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + if (currentActor.role !== 'geral') { + throw new Error('Somente a coordenação geral pode alterar coordenadores.') + } + + const scopedResult = await payload.find({ + collection: 'electoralNucleus', + where: { slug: { equals: data.slug } }, + depth: 0, + limit: 1, + pagination: false, + select: { updatedAt: true }, + user: currentActor, + overrideAccess: false, + req, + }) + const scopedNucleus = scopedResult.docs[0] + if (!scopedNucleus) { + throw new Error('Núcleo não encontrado ou sem acesso.') + } + + await acquireTextAdvisoryLocks(payload, req, [ + `coordinator-assignment:${scopedNucleus.id}`, + ]) + await acquireNucleusRowLocks({ payload, ...req } as never, [scopedNucleus.id]) + + // Intentional admin bypass: the actor is freshly confirmed as geral and the scoped nucleus + // is locked before revision, eligibility, and assignment checks. + const currentNucleus = await payload.findByID({ + collection: 'electoralNucleus', + id: scopedNucleus.id, + depth: 0, + select: { updatedAt: true }, + overrideAccess: true, + req, + }) + if (currentNucleus.updatedAt !== data.expectedUpdatedAt) { + throw new Error( + 'A coordenação deste núcleo foi alterada. Atualize a página e tente novamente.', + ) + } + + if (data.coordinatorIds.length > 0) { + const eligible = await payload.find({ + collection: 'campaignUser', + where: { + and: [{ id: { in: data.coordinatorIds } }, eligibleNucleusCoordinatorWhere], + }, + depth: 0, + pagination: false, + select: { name: true }, + overrideAccess: true, + req, + }) + if (eligible.docs.length !== data.coordinatorIds.length) { + throw new Error( + 'Uma ou mais pessoas selecionadas não são mais elegíveis para coordenação.', + ) + } + } + + return payload.update({ + collection: 'electoralNucleus', + id: currentNucleus.id, + data: { coordinators: data.coordinatorIds }, + depth: 0, + overrideAccess: true, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar a transação da designação.' }, + ) +} + +export const assignNucleusCoordinators = async (input: CoordinatorAssignmentInput) => { + const { payload, actor } = await getCampaignActionContext() + return assignNucleusCoordinatorsRecord(payload, actor, input) +} diff --git a/src/app/(campaign)/campanha/actions/invite.ts b/src/app/(campaign)/campanha/actions/invite.ts new file mode 100644 index 00000000..c988226b --- /dev/null +++ b/src/app/(campaign)/campanha/actions/invite.ts @@ -0,0 +1,44 @@ +'use server' + +import config from '@payload-config' +import { headers } from 'next/headers' +import { getPayload } from 'payload' + +import type { + CampaignInviteAutofillInput, + CampaignInviteCreateInput, + CampaignInviteLoginInput, +} from '@/lib/schemas/invite' +import { getCampaignActionContext } from '@/utilities/campaignActionContext' +import { setCampaignAuthCookie } from '@/utilities/campaignAuth' +import { createCampaignInviteForActor } from '@/utilities/campaignInviteCreation' +import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin' +import { + redeemCampaignInviteAutofillRecord, + redeemCampaignInviteLoginRecord, +} from '@/utilities/campaignInviteRedemption' + +export const createCampaignInvite = async (input: CampaignInviteCreateInput) => { + const [{ payload, actor }, requestHeaders] = await Promise.all([ + getCampaignActionContext(), + headers(), + ]) + const inviteBaseURL = getCampaignInviteBaseURL({ + requestOrigin: requestHeaders.get('origin'), + forwardedHost: requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host'), + forwardedProto: requestHeaders.get('x-forwarded-proto'), + }) + return createCampaignInviteForActor(payload, actor, input, inviteBaseURL) +} + +export const redeemCampaignInviteAutofill = async (input: CampaignInviteAutofillInput) => { + const payload = await getPayload({ config }) + return redeemCampaignInviteAutofillRecord(payload, input) +} + +export const redeemCampaignInviteLogin = async (input: CampaignInviteLoginInput) => { + const payload = await getPayload({ config }) + const result = await redeemCampaignInviteLoginRecord(payload, input) + await setCampaignAuthCookie(result.token, payload) + return { ok: true as const } +} diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts new file mode 100644 index 00000000..ce1d5db8 --- /dev/null +++ b/src/app/(campaign)/campanha/actions/leadership.ts @@ -0,0 +1,202 @@ +'use server' + +import type { Payload } from 'payload' + +import { + leadershipCreateSchema, + leadershipInternalUpdateSchema, + type LeadershipInternalUpdateInput, +} from '@/lib/schemas/leadership' +import type { CampaignUser, Contact } from '@/payload-types' +import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' +import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant' +import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' +import { withPayloadTransaction } from '@/utilities/payloadTransaction' +import { requireRelationshipId } from '@/utilities/relationship' + +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 lideranças.') + } + + 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 isUniqueLeadershipConflict = (error: unknown): boolean => { + const message = error instanceof Error ? error.message : String(error) + if (/leadership.*contact.*nucleus|leadership_contact_nucleus|duplicate key/i.test(message)) { + return true + } + + if (!(error instanceof Error) || error.name !== 'ValidationError') return false + + const details = JSON.stringify(error) + return /contact(?:_id)?/i.test(details) && /nucleus(?:_id)?/i.test(details) +} + +type LeadershipCreateData = ReturnType + +const createValidatedLeadershipRecord = async ( + payload: Payload, + actor: CampaignUser, + data: LeadershipCreateData, +) => { + try { + return await withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await getFreshStaffActor(payload, actor, req) + const nucleus = await assertNucleusManagement(payload, currentActor, data.nucleus, req) + if (payload.db.name !== 'postgres') { + throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.') + } + await acquireContactPhoneLocks(payload, req, [data.phone]) + // Intentional admin bypass: staff scope was freshly checked; these internal reads and + // writes atomically maintain the normalized Contact ↔ Leadership join. + const contacts = await payload.find({ + collection: 'contact', + where: { phone: { equals: data.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.', + ) + } + + let contactID = contacts.docs[0]?.id + const contactReused = Boolean(contactID) + + if (!contactID) { + const city = nucleus.city ?? nucleus.locality + const contact = await payload.create({ + collection: 'contact', + data: { + name: data.name, + phone: data.phone, + email: data.email, + gender: data.gender, + state: 'BA' as Contact['state'], + city, + }, + depth: 0, + overrideAccess: true, + req, + }) + contactID = contact.id + } + + const leadership = await payload.create({ + collection: 'leadership', + data: { + contact: contactID, + nucleus: data.nucleus, + sector: data.sector, + sectorNotes: data.sectorNotes, + supportStatus: data.supportStatus, + notes: data.notes, + consentNote: data.consentNote, + createdBy: currentActor.id, + }, + depth: 0, + overrideAccess: true, + req, + }) + return { ...leadership, contactReused } + }, + { beginFailureMessage: 'Não foi possível iniciar a transação de cadastro da liderança.' }, + ) + } catch (error) { + if (isUniqueLeadershipConflict(error)) { + throw new Error('Esta pessoa já está cadastrada como liderança neste núcleo.') + } + + throw error + } +} + +export const createLeadershipRecord = async ( + payload: Payload, + actor: CampaignUser, + input: unknown, +) => createValidatedLeadershipRecord(payload, actor, leadershipCreateSchema.parse(input)) + +export const updateLeadershipInternalRecord = async ( + payload: Payload, + actor: CampaignUser, + input: LeadershipInternalUpdateInput, +) => { + const { id, ...data } = leadershipInternalUpdateSchema.parse(input) + const currentActor = await getFreshStaffActor(payload, actor) + const current = await payload.findByID({ + collection: 'leadership', + id, + depth: 0, + overrideAccess: true, + }) + await assertNucleusManagement(payload, currentActor, requireRelationshipId(current.nucleus)) + + return payload.update({ + collection: 'leadership', + id, + data, + depth: 0, + user: currentActor, + overrideAccess: false, + }) +} + +export const listNucleusLeaderships = async ( + payload: Payload, + actor: CampaignUser, + nucleusID: number, +) => { + const currentActor = await getFreshStaffActor(payload, actor) + await assertNucleusManagement(payload, currentActor, nucleusID) + + return payload.find({ + collection: 'leadership', + where: { nucleus: { equals: nucleusID } }, + depth: 1, + sort: 'createdAt', + user: currentActor, + overrideAccess: false, + }) +} + +export const createLeadership = async (input: unknown) => { + const data = leadershipCreateSchema.parse(input) + const { payload, actor } = await getCampaignActionContext() + return createValidatedLeadershipRecord(payload, actor, data) +} + +export const updateLeadershipInternal = async (input: LeadershipInternalUpdateInput) => { + const { payload, actor } = await getCampaignActionContext() + return updateLeadershipInternalRecord(payload, actor, input) +} diff --git a/src/app/(campaign)/campanha/actions/nucleus.ts b/src/app/(campaign)/campanha/actions/nucleus.ts new file mode 100644 index 00000000..177327da --- /dev/null +++ b/src/app/(campaign)/campanha/actions/nucleus.ts @@ -0,0 +1,94 @@ +'use server' + +import type { Payload } from 'payload' + +import { + nucleusCreateSchema, + nucleusUpdateSchema, + type NucleusCreateInput, + type NucleusUpdateInput, +} from '@/lib/schemas/nucleus' +import type { CampaignUser } from '@/payload-types' +import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' +import { acquireNucleusRowLocks } from '@/utilities/nucleusRowLocks' +import { withPayloadTransaction } from '@/utilities/payloadTransaction' + +export const createElectoralNucleus = async ( + payload: Payload, + actor: CampaignUser, + input: NucleusCreateInput, +) => { + const data = nucleusCreateSchema.parse(input) + + return payload.create({ + collection: 'electoralNucleus', + data: data as never, + depth: 0, + user: actor, + overrideAccess: false, + }) +} + +export const updateElectoralNucleus = async ( + payload: Payload, + actor: CampaignUser, + input: NucleusUpdateInput, +) => { + const { id, ...data } = nucleusUpdateSchema.parse(input) + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + await acquireNucleusRowLocks({ payload, ...req } as never, [id]) + + return payload.update({ + collection: 'electoralNucleus', + id, + data, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar a atualização do núcleo.' }, + ) +} + +export const archiveElectoralNucleus = async ( + payload: Payload, + actor: CampaignUser, + id: number, +) => { + const currentActor = await reloadCampaignActor(payload, actor) + + if (currentActor.role !== 'geral') { + throw new Error('Somente a coordenação geral pode arquivar núcleos.') + } + + return payload.update({ + collection: 'electoralNucleus', + id, + data: { + status: 'arquivado', + }, + depth: 0, + user: currentActor, + overrideAccess: false, + }) +} + +export const createNucleus = async (input: NucleusCreateInput) => { + const { payload, actor } = await getCampaignActionContext() + return createElectoralNucleus(payload, actor, input) +} + +export const updateNucleus = async (input: NucleusUpdateInput) => { + const { payload, actor } = await getCampaignActionContext() + return updateElectoralNucleus(payload, actor, input) +} + +export const archiveNucleus = async (id: number) => { + const { payload, actor } = await getCampaignActionContext() + return archiveElectoralNucleus(payload, actor, id) +} diff --git a/src/app/(campaign)/campanha/actions/nucleusUpdate.ts b/src/app/(campaign)/campanha/actions/nucleusUpdate.ts new file mode 100644 index 00000000..73a027c8 --- /dev/null +++ b/src/app/(campaign)/campanha/actions/nucleusUpdate.ts @@ -0,0 +1,63 @@ +'use server' + +import type { Payload } from 'payload' + +import { + nucleusUpdateCreateSchema, + type NucleusUpdateCreateInput, +} from '@/lib/schemas/nucleusUpdate' +import type { CampaignUser } from '@/payload-types' +import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' +import { withPayloadTransaction } from '@/utilities/payloadTransaction' + +export const createNucleusUpdateRecord = async ( + payload: Payload, + actor: CampaignUser, + input: NucleusUpdateCreateInput, +) => { + const data = nucleusUpdateCreateSchema.parse(input) + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + return payload.create({ + collection: 'nucleusUpdate', + data: data as never, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar a transação da atualização.' }, + ) +} + +export const listNucleusUpdates = async ( + payload: Payload, + actor: CampaignUser, + nucleus: number, +) => { + const currentActor = await reloadCampaignActor(payload, actor) + await payload.findByID({ + collection: 'electoralNucleus', + id: nucleus, + depth: 0, + user: currentActor, + overrideAccess: false, + }) + + return payload.find({ + collection: 'nucleusUpdate', + where: { nucleus: { equals: nucleus } }, + depth: 1, + sort: '-createdAt', + user: currentActor, + overrideAccess: false, + }) +} + +export const createNucleusUpdate = async (input: NucleusUpdateCreateInput) => { + const { payload, actor } = await getCampaignActionContext() + return createNucleusUpdateRecord(payload, actor, input) +} diff --git a/src/app/(campaign)/campanha/actions/voteEstimate.ts b/src/app/(campaign)/campanha/actions/voteEstimate.ts new file mode 100644 index 00000000..92476d4e --- /dev/null +++ b/src/app/(campaign)/campanha/actions/voteEstimate.ts @@ -0,0 +1,179 @@ +'use server' + +import { randomUUID } from 'node:crypto' +import type { Payload } from 'payload' + +import { + confirmVoteEstimateSchema, + suggestVoteEstimateSchema, + type ConfirmVoteEstimateInput, + type SuggestVoteEstimateInput, +} from '@/lib/schemas/voteEstimate' +import type { CampaignUser, ElectoralNucleus } from '@/payload-types' +import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' +import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' +import { withPayloadTransaction } from '@/utilities/payloadTransaction' +import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' + +const assertSuggestionScope = async ( + payload: Payload, + actor: CampaignUser, + nucleus: number, + req: PayloadTransactionRequest, +): Promise => { + if (actor.role === 'geral') return + + if (actor.role === 'coordenador') { + await payload.findByID({ + collection: 'electoralNucleus', + id: nucleus, + depth: 0, + user: actor, + overrideAccess: false, + req, + }) + return + } + + const links = await payload.find({ + collection: 'leadership', + where: { + and: [ + { nucleus: { equals: nucleus } }, + { user: { equals: actor.id } }, + { supportStatus: { equals: 'engajado' } }, + ], + }, + depth: 0, + limit: 1, + pagination: false, + overrideAccess: true, + req, + }) + + if (links.totalDocs === 0) { + throw new Error('A liderança precisa de vínculo engajado com este núcleo.') + } +} + +const getConfirmableNucleus = async ( + payload: Payload, + actor: CampaignUser, + nucleus: number, + req: PayloadTransactionRequest, +): Promise => { + if (actor.role !== 'geral' && actor.role !== 'coordenador') { + throw new Error('Somente a coordenação pode confirmar estimativas.') + } + + return payload.findByID({ + collection: 'electoralNucleus', + id: nucleus, + depth: 0, + user: actor, + overrideAccess: false, + req, + }) +} + +export const suggestVoteEstimateRecord = async ( + payload: Payload, + actor: CampaignUser, + input: SuggestVoteEstimateInput, +) => { + const data = suggestVoteEstimateSchema.parse(input) + return withPayloadTransaction( + payload, + async ({ req }) => { + if (payload.db.name !== 'postgres') { + throw new Error('O bloqueio de estimativa exige o adaptador PostgreSQL.') + } + await acquireTextAdvisoryLocks(payload, req, [`vote-estimate:${data.nucleus}`]) + const currentActor = await reloadCampaignActor(payload, actor, req) + await assertSuggestionScope(payload, currentActor, data.nucleus, req) + + // Intentional admin bypass: scope was checked under the nucleus transaction lock; audit + // fields and proposal version are derived exclusively on the server. + return payload.update({ + collection: 'electoralNucleus', + id: data.nucleus, + data: { + proposedVoteEstimate: data.estimate, + proposedVoteEstimateAt: new Date().toISOString(), + proposedVoteEstimateBy: currentActor.id, + proposedVoteEstimateVersion: randomUUID(), + }, + depth: 0, + overrideAccess: true, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar a transação da estimativa.' }, + ) +} + +export const confirmVoteEstimateRecord = async ( + payload: Payload, + actor: CampaignUser, + input: ConfirmVoteEstimateInput, +) => { + const data = confirmVoteEstimateSchema.parse(input) + return withPayloadTransaction( + payload, + async ({ req }) => { + if (payload.db.name !== 'postgres') { + throw new Error('O bloqueio de estimativa exige o adaptador PostgreSQL.') + } + await acquireTextAdvisoryLocks(payload, req, [`vote-estimate:${data.nucleus}`]) + const currentActor = await reloadCampaignActor(payload, actor, req) + const nucleus = await getConfirmableNucleus(payload, currentActor, data.nucleus, req) + const proposal = nucleus.proposedVoteEstimate + const proposalVersion = nucleus.proposedVoteEstimateVersion ?? null + + if (proposal == null) { + if (proposalVersion !== null || data.expectedProposedVoteEstimateVersion !== null) { + throw new Error('A sugestão foi alterada. Atualize a página antes de confirmar.') + } + } else if ( + proposalVersion === null || + proposalVersion !== data.expectedProposedVoteEstimateVersion + ) { + throw new Error('A sugestão foi alterada. Atualize a página antes de confirmar.') + } + if ((proposal == null || data.estimate !== proposal) && !data.confirmationNote) { + throw new Error('Informe uma justificativa para ajustar a estimativa.') + } + + // Intentional admin bypass: confirmation role/scope and proposal revision were checked + // under the same transaction lock; all audit fields are server-derived. + return payload.update({ + collection: 'electoralNucleus', + id: data.nucleus, + data: { + confirmedVoteEstimate: data.estimate, + confirmedVoteEstimateAt: new Date().toISOString(), + confirmedVoteEstimateBy: currentActor.id, + confirmationNote: data.confirmationNote ?? null, + proposedVoteEstimate: null, + proposedVoteEstimateAt: null, + proposedVoteEstimateBy: null, + proposedVoteEstimateVersion: null, + }, + depth: 0, + overrideAccess: true, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar a transação da estimativa.' }, + ) +} + +export const suggestVoteEstimate = async (input: SuggestVoteEstimateInput) => { + const { payload, actor } = await getCampaignActionContext() + return suggestVoteEstimateRecord(payload, actor, input) +} + +export const confirmVoteEstimate = async (input: ConfirmVoteEstimateInput) => { + const { payload, actor } = await getCampaignActionContext() + return confirmVoteEstimateRecord(payload, actor, input) +} diff --git a/src/app/(campaign)/campanha/convite/[token]/formActions.ts b/src/app/(campaign)/campanha/convite/[token]/formActions.ts new file mode 100644 index 00000000..0b2a974c --- /dev/null +++ b/src/app/(campaign)/campanha/convite/[token]/formActions.ts @@ -0,0 +1,104 @@ +'use server' + +import { redirect } from 'next/navigation' +import { ZodError } from 'zod' + +import { + redeemCampaignInviteAutofill, + redeemCampaignInviteLogin, +} from '@/app/(campaign)/campanha/actions/invite' +import { campaignInviteAutofillSchema, campaignInviteLoginSchema } from '@/lib/schemas/invite' +import { + checkboxFormValue, + FormDataBoundaryError, + nullableFormText, + requiredFormSecret, + requiredFormText, + validationFieldErrors, +} from '@/lib/formData' + +export type CampaignInviteFormState = { + status?: 'success' + message?: string + fieldErrors?: Record +} + +export type BoundCampaignInviteFormAction = ( + state: CampaignInviteFormState, + formData: FormData, +) => Promise + +const profileFromForm = (formData: FormData) => ({ + name: requiredFormText(formData, 'name'), + phone: requiredFormText(formData, 'phone'), + email: nullableFormText(formData, 'email'), + gender: nullableFormText(formData, 'gender'), + sector: nullableFormText(formData, 'sector'), + sectorNotes: nullableFormText(formData, 'sectorNotes'), +}) + +const inviteFormError = (error: unknown): CampaignInviteFormState => { + if (error instanceof FormDataBoundaryError) { + return { fieldErrors: { [error.field]: [error.message] } } + } + if (error instanceof ZodError) { + return { fieldErrors: validationFieldErrors(error) } + } + return { + message: 'Este convite não está disponível. Peça um novo convite à pessoa que falou com você.', + } +} + +export const redeemCampaignInviteAutofillFormAction = async ( + token: string, + _state: CampaignInviteFormState, + formData: FormData, +): Promise => { + try { + const input = campaignInviteAutofillSchema.parse({ + token, + ...profileFromForm(formData), + consentAccepted: checkboxFormValue(formData, 'consentAccepted'), + }) + await redeemCampaignInviteAutofill(input) + return { + status: 'success', + message: 'Seus dados foram confirmados com sucesso.', + } + } catch (error) { + return inviteFormError(error) + } +} + +export const redeemCampaignInviteLoginFormAction = async ( + token: string, + _state: CampaignInviteFormState, + formData: FormData, +): Promise => { + let password: string + try { + password = requiredFormSecret(formData, 'password') + if (password !== requiredFormSecret(formData, 'passwordConfirmation')) { + return { + fieldErrors: { + passwordConfirmation: ['As senhas não coincidem.'], + }, + } + } + } catch (error) { + return inviteFormError(error) + } + try { + const input = campaignInviteLoginSchema.parse({ + token, + ...profileFromForm(formData), + password, + consentAccepted: checkboxFormValue(formData, 'consentAccepted'), + }) + await redeemCampaignInviteLogin(input) + } catch (error) { + return inviteFormError(error) + } + + redirect('/campanha') +} diff --git a/src/app/(campaign)/campanha/convite/[token]/page.tsx b/src/app/(campaign)/campanha/convite/[token]/page.tsx new file mode 100644 index 00000000..0061fd5c --- /dev/null +++ b/src/app/(campaign)/campanha/convite/[token]/page.tsx @@ -0,0 +1,65 @@ +import config from '@payload-config' +import type { Metadata } from 'next' +import { unstable_noStore as noStore } from 'next/cache' +import { getPayload } from 'payload' + +import { + redeemCampaignInviteAutofillFormAction, + redeemCampaignInviteLoginFormAction, +} from '@/app/(campaign)/campanha/convite/[token]/formActions' +import { CampaignInviteForm } from '@/components/campaign/CampaignInviteForm' +import { ConsentText } from '@/components/campaign/ConsentText' +import { InvalidCampaignInvite } from '@/components/campaign/InvalidCampaignInvite' +import { getCampaignInvitePageData } from '@/utilities/campaignInvitePageData' + +export const dynamic = 'force-dynamic' +export const fetchCache = 'force-no-store' +export const revalidate = 0 + +export const metadata: Metadata = { + title: 'Confirme seus dados | Campanha do Solla', + description: 'Página segura para confirmar seus dados com a campanha do Solla.', + referrer: 'no-referrer', + robots: { + index: false, + follow: false, + nocache: true, + }, +} + +type CampaignInvitePageProps = { + params: Promise<{ token: string }> +} + +export default async function CampaignInvitePage({ params }: CampaignInvitePageProps) { + noStore() + const [{ token }, payload] = await Promise.all([params, getPayload({ config })]) + const preview = await getCampaignInvitePageData(payload, token) + + if (preview.status === 'invalid') { + return ( +
+ +
+ ) + } + + const action = + preview.kind === 'login' + ? redeemCampaignInviteLoginFormAction.bind(null, token) + : redeemCampaignInviteAutofillFormAction.bind(null, token) + const consentText = preview.requiresConsent ? ConsentText({ data: preview.consentData }) : undefined + + return ( +
+ + {consentText} + +
+ ) +} diff --git a/src/app/(campaign)/campanha/login/LoginForm.tsx b/src/app/(campaign)/campanha/login/LoginForm.tsx new file mode 100644 index 00000000..ad052a98 --- /dev/null +++ b/src/app/(campaign)/campanha/login/LoginForm.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useActionState } from 'react' + +import { loginCampaignFormAction } from '@/app/(campaign)/campanha/actions/auth' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field' +import { Input } from '@/components/ui/input' +import { Spinner } from '@/components/ui/Spinner' + +export const LoginForm = () => { + const [state, formAction, pending] = useActionState(loginCampaignFormAction, {}) + + return ( + + + Acessar painel + Entre com seu e-mail ou celular para continuar. + + +
+ + + E-mail ou celular + + + + Senha + + + + {state.error ? {state.error} : null} + + + +
+
+
+ ) +} diff --git a/src/app/(campanha)/campanha/login/page.tsx b/src/app/(campaign)/campanha/login/page.tsx similarity index 100% rename from src/app/(campanha)/campanha/login/page.tsx rename to src/app/(campaign)/campanha/login/page.tsx diff --git a/src/app/(campanha)/layout.tsx b/src/app/(campaign)/layout.tsx similarity index 76% rename from src/app/(campanha)/layout.tsx rename to src/app/(campaign)/layout.tsx index e112f657..0f7d9d84 100644 --- a/src/app/(campanha)/layout.tsx +++ b/src/app/(campaign)/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from 'next' import { Inter } from 'next/font/google' import React from 'react' +import { VoteEstimateFocusProvider } from '@/components/campaign/VoteEstimateFocusProvider' + import '../(frontend)/styles.css' const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }) @@ -22,7 +24,9 @@ export default function CampanhaRootLayout({ children }: { children: React.React className={inter.variable} style={{ colorScheme: 'light' }} > - {children} + + {children} + ) } diff --git a/src/app/(campanha)/campanha/(app)/layout.tsx b/src/app/(campanha)/campanha/(app)/layout.tsx deleted file mode 100644 index fee8954f..00000000 --- a/src/app/(campanha)/campanha/(app)/layout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { redirect } from 'next/navigation' -import React from 'react' - -import { CampaignSidebar } from '@/components/campanha/campaign-sidebar' -import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' -import { getCampaignUser } from '@/utilities/campaignAuth' - -export default async function CampaignAppLayout({ children }: { children: React.ReactNode }) { - const user = await getCampaignUser() - - if (!user) { - redirect('/campanha/login') - } - - return ( - - - -
- -
-
{children}
-
-
- ) -} diff --git a/src/app/(campanha)/campanha/(app)/page.tsx b/src/app/(campanha)/campanha/(app)/page.tsx deleted file mode 100644 index f782ef45..00000000 --- a/src/app/(campanha)/campanha/(app)/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export default function CampaignHomePage() { - return ( -
-

Painel da campanha

-

- Em breve: ferramentas de gerenciamento da campanha. -

-
- ) -} diff --git a/src/app/(campanha)/campanha/login/LoginForm.tsx b/src/app/(campanha)/campanha/login/LoginForm.tsx deleted file mode 100644 index 8367f543..00000000 --- a/src/app/(campanha)/campanha/login/LoginForm.tsx +++ /dev/null @@ -1,97 +0,0 @@ -'use client' - -import { zodResolver } from '@hookform/resolvers/zod' -import { useTransition } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' - -import { loginCampaign } from '@/app/(campanha)/campanha/actions/auth' -import { Button } from '@/components/ui/button' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' -import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field' -import { Input } from '@/components/ui/input' -import { campaignLoginSchema, type CampaignLoginInput } from '@/lib/schemas/campaign-login' - -export const LoginForm = () => { - const methods = useForm({ - resolver: zodResolver(campaignLoginSchema), - defaultValues: { - email: '', - password: '', - }, - }) - - const [isSubmitting, startTransition] = useTransition() - - const handleSubmit: SubmitHandler = (input) => { - startTransition(async () => { - const result = await loginCampaign(input) - - if (result?.error) { - methods.setError('root', { message: result.error }) - } - }) - } - - const { - register, - formState: { errors }, - } = methods - - return ( - - - Acessar painel - Entre com seu e-mail e senha para continuar. - - -
- - - E-mail - - {errors.email?.message ? ( - - {errors.email.message} - - ) : null} - - - Senha - - {errors.password?.message ? ( - - {errors.password.message} - - ) : null} - - - {errors.root?.message ? {errors.root.message} : null} - - - -
-
-
- ) -} diff --git a/src/app/(frontend)/[type]/[category]/[slug]/page.tsx b/src/app/(frontend)/[type]/[category]/[slug]/page.tsx index 75aae2b3..da2f3fc9 100644 --- a/src/app/(frontend)/[type]/[category]/[slug]/page.tsx +++ b/src/app/(frontend)/[type]/[category]/[slug]/page.tsx @@ -1,7 +1,10 @@ +import type { Crumb } from '@/components/SiteHeader' +import { SiteHeader } from '@/components/SiteHeader' +import { Badge } from '@/components/ui/Badge' +import type { Media, Post } from '@/payload-types' import { getCachedDocumentById } from '@/utilities/documents' -import { getCachedGlobal } from '@/utilities/globals' import { extractFirstImageFromLexical } from '@/utilities/extractFirstImageFromLexical' -import { stripTrailingSlash, toAbsoluteUrl, truncate } from '@/utilities/seo' +import { getCachedGlobal } from '@/utilities/globals' import { POST_TYPE_LABELS, formatPostDate, @@ -15,15 +18,12 @@ import { isPostType, isPostVisible, } from '@/utilities/posts' -import { SiteHeader } from '@/components/SiteHeader' -import type { Crumb } from '@/components/SiteHeader' -import { Badge } from '@/components/ui/badge' +import { stripTrailingSlash, toAbsoluteUrl, truncate } from '@/utilities/seo' import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html' -import type { Media, Post } from '@/payload-types' import type { Metadata } from 'next' -import type { Article, WithContext } from 'schema-dts' import Image from 'next/image' import { notFound, redirect } from 'next/navigation' +import type { Article, WithContext } from 'schema-dts' export const dynamicParams = true @@ -235,7 +235,7 @@ export default async function Page({ params }: { params: Promise }) {coverImage?.url ? (
-
+
{coverImage.alt { const payload = await getCachedGlobal('metadata')() @@ -21,9 +29,9 @@ export async function generateMetadata(): Promise { } return { - title: payload.title, - description: payload.description, - keywords: payload.keywords.filter((keyword) => typeof keyword === 'string'), + title: payload.title || fallbackMetadata.title, + description: payload.description || fallbackMetadata.description, + keywords: (payload.keywords ?? []).map(({ keyword }) => keyword).filter(Boolean), authors: [ { name: 'Francisco Solla', url: 'https://solla.dev' }, { name: 'Teqo', url: 'https://teqo.app' }, @@ -37,10 +45,10 @@ export async function generateMetadata(): Promise { openGraph: { type: 'website', locale: 'pt-BR', - url: payload.URL, - siteName: payload.openGraph.siteName, - title: payload.title, - description: payload.description, + url: payload.URL || fallbackMetadata.URL, + siteName: payload.openGraph?.siteName || fallbackMetadata.openGraphSiteName, + title: payload.title || fallbackMetadata.title, + description: payload.description || fallbackMetadata.description, images: image?.url ? [ { @@ -54,9 +62,10 @@ export async function generateMetadata(): Promise { }, twitter: { card: 'summary_large_image', - title: payload.title, - description: payload.twitter.description, - creator: payload.twitter.creator, + title: payload.title || fallbackMetadata.title, + description: + payload.twitter?.description || payload.description || fallbackMetadata.description, + creator: payload.twitter?.creator || fallbackMetadata.twitterCreator, images: image?.url ? [image.url] : [], }, } diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index 86368e9b..adbe5266 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -2,7 +2,6 @@ @plugin 'tailwindcss-animate'; @import 'tw-animate-css'; -@import 'tw-animate-css'; @import 'shadcn/tailwind.css'; @custom-variant dark (&:is(.dark *)); @@ -62,6 +61,24 @@ body, --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); + --color-scope-border: var(--scope-border); + --color-scope-foreground: var(--scope-foreground); + --color-scope: var(--scope); + --color-tse-foreground: var(--tse-foreground); + --color-tse: var(--tse); + --color-support-engaged-foreground: var(--support-engaged-foreground); + --color-support-engaged: var(--support-engaged); + --color-support-to-approach-foreground: var(--support-to-approach-foreground); + --color-support-to-approach: var(--support-to-approach); + --color-support-disputed-foreground: var(--support-disputed-foreground); + --color-support-disputed: var(--support-disputed); + --color-support-negative-foreground: var(--support-negative-foreground); + --color-support-negative: var(--support-negative); + --color-estimate-confirmed-foreground: var(--estimate-confirmed-foreground); + --color-estimate-confirmed: var(--estimate-confirmed); + --color-estimate-pending-foreground: var(--estimate-pending-foreground); + --color-estimate-pending: var(--estimate-pending); + --color-cadence-overdue: var(--cadence-overdue); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -184,8 +201,9 @@ body, management panel). Mirrors the shadcn token contract with a brand-red primary and defines the --field-* tokens so the custom Input renders with a visible border on the white card. Applied via data-theme="campaign" on the - (campanha) layout, so it never affects the public (frontend) pages. */ + (campaign) layout, so it never affects the public (frontend) pages. */ [data-theme='campaign'] { + --radius: 0.5rem; --background: #ffffff; --foreground: #1c1917; --card: #ffffff; @@ -220,6 +238,51 @@ body, --sidebar-accent-foreground: #1c1917; --sidebar-border: rgb(28 25 23 / 12%); --sidebar-ring: #c51414; + --scope: #fff5ed; + --scope-foreground: #6b2d15; + --scope-border: #e8c5ae; + --tse: #f1f3f5; + --tse-foreground: #3f4854; + --support-engaged: #dcfce7; + --support-engaged-foreground: #166534; + --support-to-approach: #eef0f3; + --support-to-approach-foreground: #46505c; + --support-disputed: #fef3c7; + --support-disputed-foreground: #92400e; + --support-negative: #fee2e2; + --support-negative-foreground: #991b1b; + --estimate-confirmed: #dcfce7; + --estimate-confirmed-foreground: #166534; + --estimate-pending: #fef3c7; + --estimate-pending-foreground: #92400e; + --cadence-overdue: #b42318; +} + +[data-theme='campaign'] + :is( + [data-slot='input'], + [data-slot='input-group'], + [data-slot='combobox-trigger'], + [data-slot='native-select'], + [data-slot='select-trigger'] + ) { + border-radius: var(--radius-md); +} + +/* The public-site base `h1` is centered + display-sized for article heroes. + Campaign dashboard headings are left-aligned section titles, so undo the + base centering and text balancing here; each heading keeps its own + size/weight utility classes. The base `p { not-first:mt-6 }` is also for + article body flow — campaign layouts space subtitles via flex gap, so the + extra top margin would stack on top of the gap and create a large gap + between a title and its subtitle. */ +[data-theme='campaign'] h1 { + text-align: left; + text-wrap: initial; +} + +[data-theme='campaign'] p { + margin-top: 0; } /* @@ -302,7 +365,7 @@ body, } p { - @apply leading-7 [&:not(:first-child)]:mt-6; + @apply leading-7 not-first:mt-6; } blockquote { diff --git a/src/collections/CampaignInvite.ts b/src/collections/CampaignInvite.ts new file mode 100644 index 00000000..6db28466 --- /dev/null +++ b/src/collections/CampaignInvite.ts @@ -0,0 +1,122 @@ +import type { CollectionConfig } from 'payload' + +import { + canCreateCampaignInvite, + canMutateCampaignInvite, + canReadCampaignInvite, + canSetCampaignInviteSystemField, +} from '@/utilities/campaignAccess' +import { campaignInviteExpiry } from '@/utilities/campaignInvite' + +export const CampaignInvite: CollectionConfig = { + slug: 'campaignInvite', + labels: { + singular: 'Convite', + plural: 'Convites', + }, + admin: { + group: 'Campanha', + useAsTitle: 'leadership', + defaultColumns: ['leadership', 'kind', 'expiresAt', 'usedAt', 'revokedAt', 'createdAt'], + }, + access: { + create: canCreateCampaignInvite, + read: canReadCampaignInvite, + update: canMutateCampaignInvite, + delete: canMutateCampaignInvite, + }, + indexes: [ + { + fields: ['leadership', 'kind'], + }, + ], + fields: [ + { + name: 'tokenHash', + type: 'text', + required: true, + unique: true, + index: true, + admin: { + hidden: true, + }, + access: { + create: canSetCampaignInviteSystemField, + read: () => false, + update: canSetCampaignInviteSystemField, + }, + }, + { + name: 'leadership', + type: 'relationship', + relationTo: 'leadership', + label: 'Liderança', + required: true, + index: true, + }, + { + name: 'kind', + type: 'select', + label: 'Tipo', + required: true, + index: true, + options: [ + { label: 'Acesso ao app', value: 'login' }, + { label: 'Completar cadastro', value: 'autopreenchimento' }, + ], + }, + { + name: 'expiresAt', + type: 'date', + label: 'Expira em', + required: true, + index: true, + defaultValue: () => campaignInviteExpiry().toISOString(), + access: { + create: canSetCampaignInviteSystemField, + update: canSetCampaignInviteSystemField, + }, + }, + { + name: 'usedAt', + type: 'date', + label: 'Usado em', + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignInviteSystemField, + update: canSetCampaignInviteSystemField, + }, + }, + { + name: 'revokedAt', + type: 'date', + label: 'Revogado em', + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignInviteSystemField, + update: canSetCampaignInviteSystemField, + }, + }, + { + name: 'createdBy', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Criado por', + required: true, + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignInviteSystemField, + update: canSetCampaignInviteSystemField, + }, + }, + ], +} diff --git a/src/collections/CampaignUser.ts b/src/collections/CampaignUser.ts index 1242ac63..072197ff 100644 --- a/src/collections/CampaignUser.ts +++ b/src/collections/CampaignUser.ts @@ -1,4 +1,79 @@ -import type { CollectionConfig } from 'payload' +import type { CampaignUser as CampaignUserDocument } from '@/payload-types' +import { + canCreateCampaignUserPhone, + canManageCampaignUsers, + canManageCampaignUserRole, + canReadCampaignUserIdentity, + canReadCampaignUserPhone, + canReadCampaignUsers, + canUpdateCampaignUserPhone, +} from '@/utilities/campaignAccess' +import { normalizeBrazilianPhone } from '@/utilities/phone' +import { + APIError, + type CollectionAfterReadHook, + type CollectionBeforeChangeHook, + type CollectionConfig, +} from 'payload' + +const removePrivateAuthFields: CollectionAfterReadHook = async ({ + doc, + req, +}) => { + const user = req.user + + if (!user || user.collection === 'users' || String(user.id) === String(doc.id)) { + return doc + } + + const privateDoc = doc as unknown as Record + delete privateDoc.email + delete privateDoc.username + delete privateDoc.sessions + delete privateDoc.salt + delete privateDoc.hash + delete privateDoc.resetPasswordToken + delete privateDoc.resetPasswordExpiration + + return doc +} + +const preventAssignedCoordinatorDowngrade: CollectionBeforeChangeHook = async ({ + data, + operation, + originalDoc, + req, +}) => { + if ( + operation !== 'update' || + !originalDoc || + data.role !== 'lideranca' || + originalDoc.role === 'lideranca' + ) { + return data + } + + const assignedNucleus = await req.payload.find({ + collection: 'electoralNucleus', + where: { + coordinators: { contains: originalDoc.id }, + }, + depth: 0, + limit: 1, + select: { name: true, status: true }, + overrideAccess: true, + req, + }) + + if (assignedNucleus.totalDocs > 0) { + throw new APIError( + 'Remova ou substitua este usuário da coordenação de todos os núcleos antes de alterar o papel para liderança.', + 409, + ) + } + + return data +} export const CampaignUser: CollectionConfig = { slug: 'campaignUser', @@ -10,7 +85,23 @@ export const CampaignUser: CollectionConfig = { group: 'Campanha', useAsTitle: 'name', }, - auth: true, + auth: { + loginWithUsername: { + allowEmailLogin: true, + requireEmail: false, + requireUsername: false, + }, + }, + access: { + create: canManageCampaignUsers, + read: canReadCampaignUsers, + update: canManageCampaignUsers, + delete: canManageCampaignUsers, + }, + hooks: { + beforeChange: [preventAssignedCoordinatorDowngrade], + afterRead: [removePrivateAuthFields], + }, fields: [ { name: 'name', @@ -18,5 +109,80 @@ export const CampaignUser: CollectionConfig = { label: 'Nome', required: true, }, + { + name: 'role', + type: 'select', + label: 'Papel', + required: true, + defaultValue: 'lideranca', + saveToJWT: true, + options: [ + { label: 'Coordenação geral', value: 'geral' }, + { label: 'Coordenador', value: 'coordenador' }, + { label: 'Liderança', value: 'lideranca' }, + ], + access: { + update: canManageCampaignUserRole, + }, + }, + { + name: 'email', + type: 'email', + label: 'E-mail', + unique: true, + index: true, + access: { + read: canReadCampaignUserIdentity, + }, + }, + { + name: 'phone', + type: 'text', + label: 'Celular de contato', + index: true, + access: { + create: canCreateCampaignUserPhone, + read: canReadCampaignUserPhone, + update: canUpdateCampaignUserPhone, + }, + hooks: { + beforeValidate: [ + ({ value }) => { + if (typeof value !== 'string' || value.length === 0) return value + + return normalizeBrazilianPhone(value) ?? value + }, + ], + }, + validate: (value: unknown) => { + if (value === undefined || value === null || value === '') return true + + return normalizeBrazilianPhone(String(value)) ? true : 'Informe um celular válido com DDD.' + }, + }, + { + name: 'username', + type: 'text', + label: 'Celular de acesso', + unique: true, + index: true, + access: { + read: canReadCampaignUserIdentity, + }, + hooks: { + beforeValidate: [ + ({ value }) => { + if (typeof value !== 'string' || value.length === 0) return value + + return normalizeBrazilianPhone(value) ?? value + }, + ], + }, + validate: (value: unknown) => { + if (value === undefined || value === null || value === '') return true + + return normalizeBrazilianPhone(String(value)) ? true : 'Informe um celular válido com DDD.' + }, + }, ], } diff --git a/src/collections/Consent.ts b/src/collections/Consent.ts index 4e4de000..69df3b24 100644 --- a/src/collections/Consent.ts +++ b/src/collections/Consent.ts @@ -11,6 +11,17 @@ export const Consent: CollectionConfig = { useAsTitle: 'text', }, fields: [ + { + name: 'key', + type: 'text', + label: 'Chave estável', + required: false, + unique: true, + index: true, + admin: { + description: 'Identificador estável para referências no código.', + }, + }, { name: 'text', type: 'richText', diff --git a/src/collections/Contact.ts b/src/collections/Contact.ts index cc933799..01d7ff82 100644 --- a/src/collections/Contact.ts +++ b/src/collections/Contact.ts @@ -1,5 +1,41 @@ import { CitiesByState } from '@/lib/cities' -import { CollectionConfig } from 'payload' +import { + acquireContactPhoneLocks, + assertContactPhoneAvailable, +} from '@/utilities/contactPhoneInvariant' +import { + canManageContacts, + canReadContacts, +} from '@/utilities/campaignAccess' +import { normalizeBrazilianPhone } from '@/utilities/phone' +import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload' +import { APIError } from 'payload' + +const enforceUniqueContactPhone: CollectionBeforeChangeHook = async ({ + data, + operation, + originalDoc, + req, +}) => { + const phone = String(data.phone ?? originalDoc?.phone ?? '') + const oldPhone = + operation === 'update' && typeof originalDoc?.phone === 'string' + ? originalDoc.phone + : undefined + + await acquireContactPhoneLocks( + req.payload, + req, + oldPhone === undefined ? [phone] : [oldPhone, phone], + ) + await assertContactPhoneAvailable( + req.payload, + req, + phone, + operation === 'update' ? Number(originalDoc?.id) : undefined, + ) + return data +} export const Contact: CollectionConfig = { slug: 'contact', @@ -11,6 +47,36 @@ export const Contact: CollectionConfig = { group: 'Contatos', useAsTitle: 'name', }, + access: { + create: canManageContacts, + read: canReadContacts, + update: canManageContacts, + delete: canManageContacts, + }, + hooks: { + beforeValidate: [ + ({ data }) => { + if (!data || data.phone === undefined) return data + + const input = String(data.phone) + if (/^\d{11}$/.test(input)) { + if (!/^[1-9]{2}9\d{8}$/.test(input)) { + throw new APIError('Celular brasileiro inválido.', 400) + } + return data + } + + const phone = normalizeBrazilianPhone(input) + if (!phone) { + throw new APIError('Celular brasileiro inválido.', 400) + } + + data.phone = phone + return data + }, + ], + beforeChange: [enforceUniqueContactPhone], + }, fields: [ { name: 'name', @@ -24,7 +90,7 @@ export const Contact: CollectionConfig = { name: 'email', type: 'email', label: 'E-mail', - required: true, + required: false, }, { name: 'phone', @@ -33,6 +99,18 @@ export const Contact: CollectionConfig = { minLength: 11, maxLength: 11, required: true, + index: true, + }, + { + name: 'gender', + type: 'select', + label: 'Gênero', + options: [ + { label: 'Feminino', value: 'feminino' }, + { label: 'Masculino', value: 'masculino' }, + { label: 'Outro', value: 'outro' }, + { label: 'Não informado', value: 'nao_informado' }, + ], }, { name: 'state', @@ -47,7 +125,7 @@ export const Contact: CollectionConfig = { label: 'Cidade', minLength: 2, maxLength: 100, - required: true, + required: false, }, { name: 'postalCode', diff --git a/src/collections/ElectoralNucleus.ts b/src/collections/ElectoralNucleus.ts new file mode 100644 index 00000000..427dd2b6 --- /dev/null +++ b/src/collections/ElectoralNucleus.ts @@ -0,0 +1,582 @@ +import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload' +import { APIError } from 'payload' + +import { + isBahiaIdentityTerritory, + isBahiaMunicipality, + validateBahiaTerritoryPair, +} from '@/lib/bahiaTerritories' +import { + canCreateElectoralNucleus, + canCreateNucleusCoordinators, + canDeleteElectoralNucleus, + canManageNucleusCoordinators, + canManageNucleusLifecycle, + canReadLeadershipInternal, + canReadElectoralNucleus, + canSetDerivedNucleusField, + canUpdateElectoralNucleus, +} from '@/utilities/campaignAccess' +import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorOptions' +import { acquirePrimaryContactInvariantLocks } from '@/utilities/primaryContactInvariantLock' +import { relationshipId } from '@/utilities/relationship' +import { slugify } from '@/utilities/slug' + +const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') + +const validateNucleusTerritoryAndZones: CollectionBeforeValidateHook = ({ + data, + operation, + originalDoc, +}) => { + if (!data) return data + + const nextData = operation === 'update' ? { ...originalDoc, ...data } : data + const region = trimmedText(nextData?.region) + const city = trimmedText(nextData?.city) + const neighborhood = trimmedText(nextData?.neighborhood) + const locality = trimmedText(nextData?.locality) + const territoryNotes = trimmedText(nextData?.territoryNotes) + + if (region && !isBahiaIdentityTerritory(region)) { + throw new APIError('Selecione um território de identidade válido da Bahia.', 400) + } + if (city && !isBahiaMunicipality(city)) { + throw new APIError('Selecione um município válido da Bahia.', 400) + } + if (!validateBahiaTerritoryPair(region, city)) { + throw new APIError('O município não pertence ao território de identidade selecionado.', 400) + } + if (neighborhood && !city) { + throw new APIError('Informe o município antes do bairro.', 400) + } + if (!region && !city && !locality) { + throw new APIError( + 'Informe o território de identidade, município ou localidade do núcleo.', + 400, + ) + } + + data.region = region || null + data.city = city || null + data.neighborhood = neighborhood || null + data.locality = locality || null + data.territoryNotes = territoryNotes || null + + const zones = Array.isArray(nextData.tseZones) ? nextData.tseZones : [] + const zoneNumbers = zones.map((zone: { zoneNumber?: unknown }) => zone?.zoneNumber) + + if (new Set(zoneNumbers).size !== zoneNumbers.length) { + throw new APIError('Cada Zona TSE deve aparecer apenas uma vez.', 400) + } + + return data +} + +const setCanonicalNucleusSlug: CollectionBeforeValidateHook = ({ + data, + operation, + originalDoc, +}) => { + if (!data) return data + const name = trimmedText(data.name ?? originalDoc?.name) + const slug = slugify(name) + if (!slug) { + throw new APIError('Informe um nome com letras ou números.', 400) + } + if (operation === 'update' && data.name !== undefined && name !== originalDoc?.name) { + throw new APIError('O nome do núcleo não pode ser alterado após a criação.', 409) + } + data.name = name + data.slug = operation === 'create' ? slug : originalDoc?.slug + return data +} + +const validateNucleusCoordinators: CollectionBeforeValidateHook = async ({ data, req }) => { + if (!data) return data + + if (data.coordinators === undefined) return data + const coordinatorValues = data.coordinators + const coordinatorIDs = Array.isArray(coordinatorValues) + ? [...new Set(coordinatorValues.map(relationshipId).filter((id): id is number => id !== null))] + : [] + + if (coordinatorIDs.length === 0) return data + + const eligibleCoordinators = await req.payload.find({ + collection: 'campaignUser', + depth: 0, + pagination: false, + where: { + and: [{ id: { in: coordinatorIDs } }, eligibleNucleusCoordinatorWhere], + }, + select: { name: true }, + overrideAccess: true, + req, + }) + + if (eligibleCoordinators.docs.length !== coordinatorIDs.length) { + throw new APIError('Cada responsável deve ter papel de coordenação geral ou coordenador.', 400) + } + + return data +} + +export const ElectoralNucleus: CollectionConfig = { + slug: 'electoralNucleus', + labels: { + singular: 'Núcleo eleitoral', + plural: 'Núcleos eleitorais', + }, + admin: { + group: 'Campanha', + useAsTitle: 'name', + defaultColumns: ['name', 'status', 'region', 'city', 'updatedAt'], + }, + access: { + create: canCreateElectoralNucleus, + read: canReadElectoralNucleus, + update: canUpdateElectoralNucleus, + delete: canDeleteElectoralNucleus, + }, + hooks: { + beforeValidate: [ + setCanonicalNucleusSlug, + validateNucleusTerritoryAndZones, + validateNucleusCoordinators, + ], + beforeChange: [ + ({ data, operation, req }) => { + if (operation === 'create' && req.user?.collection === 'campaignUser') { + data.createdBy = req.user.id + } + + return data + }, + async ({ data, operation, originalDoc, req }) => { + if (data.primaryContact === undefined || data.primaryContact === null) return data + + const contact = relationshipId(data.primaryContact) + const nucleus = relationshipId(originalDoc?.id) + if (!contact || operation !== 'update' || !nucleus) { + throw new APIError( + 'Escolha o contato principal entre as lideranças engajadas após criar o núcleo.', + 400, + ) + } + + await acquirePrimaryContactInvariantLocks(req, [nucleus]) + const engagedLeadership = await req.payload.find({ + collection: 'leadership', + where: { + and: [ + { nucleus: { equals: nucleus } }, + { contact: { equals: contact } }, + { supportStatus: { equals: 'engajado' } }, + ], + }, + depth: 0, + limit: 1, + overrideAccess: true, + req, + }) + + if (engagedLeadership.totalDocs === 0) { + throw new APIError( + 'O contato principal deve ser uma liderança engajada neste núcleo.', + 409, + ) + } + + return data + }, + ], + }, + fields: [ + { + name: 'name', + type: 'text', + label: 'Nome', + required: true, + minLength: 2, + maxLength: 160, + index: true, + }, + { + name: 'slug', + type: 'text', + label: 'Slug', + required: true, + unique: true, + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetDerivedNucleusField, + update: canSetDerivedNucleusField, + }, + }, + { + name: 'status', + type: 'select', + label: 'Status', + required: true, + defaultValue: 'ativo', + index: true, + access: { + create: canSetDerivedNucleusField, + update: canManageNucleusLifecycle, + }, + options: [ + { label: 'Ativo', value: 'ativo' }, + { label: 'Arquivado', value: 'arquivado' }, + ], + }, + { + name: 'coordinators', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Coordenadores', + hasMany: true, + index: true, + access: { + create: canCreateNucleusCoordinators, + update: canManageNucleusCoordinators, + }, + filterOptions: eligibleNucleusCoordinatorWhere, + }, + { + name: 'region', + type: 'text', + label: 'Território de identidade', + index: true, + validate: (value: unknown) => + value === null || + value === undefined || + (typeof value === 'string' && isBahiaIdentityTerritory(value)) + ? true + : 'Selecione um território de identidade válido da Bahia.', + }, + { + name: 'city', + type: 'text', + label: 'Município', + maxLength: 120, + index: true, + }, + { + name: 'neighborhood', + type: 'text', + label: 'Bairro', + maxLength: 160, + }, + { + name: 'locality', + type: 'text', + label: 'Localidade', + maxLength: 160, + }, + { + name: 'territoryNotes', + type: 'textarea', + label: 'Observações do território', + maxLength: 2000, + }, + { + name: 'organizationKind', + type: 'select', + label: 'Natureza organizativa', + required: true, + defaultValue: 'territorial', + index: true, + options: [ + { label: 'Territorial', value: 'territorial' }, + { label: 'Associação', value: 'associacao' }, + { label: 'Sindicato', value: 'sindicato' }, + { label: 'Religioso', value: 'religioso' }, + { label: 'Movimento', value: 'movimento' }, + { label: 'Categoria profissional', value: 'categoria_profissional' }, + { label: 'Outro', value: 'outro' }, + ], + }, + { + name: 'organizationLabel', + type: 'text', + label: 'Nome da organização', + maxLength: 160, + }, + { + name: 'sectorKind', + type: 'select', + label: 'Setor', + index: true, + options: [ + { label: 'Rural', value: 'rural' }, + { label: 'Religioso', value: 'religioso' }, + { label: 'Sindical', value: 'sindical' }, + { label: 'Empresarial', value: 'empresarial' }, + { label: 'Juventude', value: 'juventude' }, + { label: 'Saúde', value: 'saude' }, + { label: 'Educação', value: 'educacao' }, + { label: 'Cultura', value: 'cultura' }, + { label: 'Outro', value: 'outro' }, + ], + }, + { + name: 'tseZones', + type: 'array', + label: 'Zonas TSE', + labels: { + singular: 'Zona TSE', + plural: 'Zonas TSE', + }, + fields: [ + { + name: 'zoneNumber', + type: 'number', + label: 'Número', + required: true, + min: 1, + max: 999, + validate: (value: unknown) => + typeof value === 'number' && Number.isInteger(value) + ? true + : 'O número da Zona TSE deve ser inteiro.', + }, + { + name: 'label', + type: 'text', + label: 'Descrição', + maxLength: 160, + }, + ], + }, + { + name: 'primaryContact', + type: 'relationship', + relationTo: 'contact', + label: 'Contato principal', + index: true, + }, + { + name: 'voterProfiles', + type: 'array', + label: 'Perfis do eleitorado', + labels: { + singular: 'Perfil do eleitorado', + plural: 'Perfis do eleitorado', + }, + fields: [ + { + name: 'label', + type: 'text', + label: 'Nome do perfil', + required: true, + maxLength: 120, + }, + { + name: 'ageRange', + type: 'text', + label: 'Faixa etária', + maxLength: 80, + }, + { + name: 'incomeBand', + type: 'text', + label: 'Faixa de renda', + maxLength: 80, + }, + { + name: 'occupation', + type: 'text', + label: 'Ocupação', + maxLength: 120, + }, + { + name: 'localTraits', + type: 'textarea', + label: 'Características locais', + maxLength: 500, + }, + { + name: 'notes', + type: 'textarea', + label: 'Observações', + maxLength: 1000, + }, + ], + }, + { + name: 'strengths', + type: 'array', + label: 'Forças', + fields: [ + { + name: 'text', + type: 'textarea', + label: 'Força', + required: true, + maxLength: 1000, + }, + ], + }, + { + name: 'risks', + type: 'array', + label: 'Riscos', + fields: [ + { + name: 'text', + type: 'textarea', + label: 'Risco', + required: true, + maxLength: 1000, + }, + ], + }, + { + name: 'confirmedVoteEstimate', + type: 'number', + label: 'Estimativa de votos confirmada', + min: 0, + index: true, + access: { + create: canSetDerivedNucleusField, + update: canSetDerivedNucleusField, + }, + }, + { + name: 'confirmedVoteEstimateAt', + type: 'date', + label: 'Estimativa confirmada em', + access: { + create: canSetDerivedNucleusField, + update: canSetDerivedNucleusField, + }, + }, + { + name: 'confirmedVoteEstimateBy', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Estimativa confirmada por', + access: { + create: canSetDerivedNucleusField, + update: canSetDerivedNucleusField, + }, + }, + { + name: 'confirmationNote', + type: 'textarea', + label: 'Justificativa da confirmação', + maxLength: 1000, + access: { + create: canSetDerivedNucleusField, + update: canSetDerivedNucleusField, + }, + }, + { + name: 'proposedVoteEstimate', + type: 'number', + label: 'Sugestão de estimativa de votos', + min: 0, + index: true, + access: { + create: canSetDerivedNucleusField, + update: canSetDerivedNucleusField, + }, + }, + { + name: 'proposedVoteEstimateAt', + type: 'date', + label: 'Sugestão enviada em', + access: { + create: canSetDerivedNucleusField, + update: canSetDerivedNucleusField, + }, + }, + { + name: 'proposedVoteEstimateBy', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Sugestão enviada por', + access: { + create: canSetDerivedNucleusField, + update: canSetDerivedNucleusField, + }, + }, + { + name: 'proposedVoteEstimateVersion', + type: 'text', + label: 'Versão da sugestão de estimativa', + index: true, + admin: { + readOnly: true, + }, + access: { + read: canReadLeadershipInternal, + create: () => false, + update: () => false, + }, + }, + { + name: 'ticketAlliance', + type: 'group', + label: 'Dobrada', + fields: [ + { + name: 'partnerName', + type: 'text', + label: 'Nome da parceria', + maxLength: 120, + }, + { + name: 'office', + type: 'text', + label: 'Cargo', + maxLength: 120, + }, + { + name: 'isCampaignPartner', + type: 'checkbox', + label: 'Parceiro da campanha', + defaultValue: false, + }, + { + name: 'notes', + type: 'textarea', + label: 'Observações', + maxLength: 1000, + }, + ], + }, + { + name: 'lastUpdateAt', + type: 'date', + label: 'Última atualização', + index: true, + admin: { + readOnly: true, + description: 'Preenchido automaticamente quando o domínio de atualizações for ativado.', + }, + access: { + create: canSetDerivedNucleusField, + update: canSetDerivedNucleusField, + }, + }, + { + name: 'createdBy', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Criado por', + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetDerivedNucleusField, + update: canSetDerivedNucleusField, + }, + }, + ], +} diff --git a/src/collections/Leadership.ts b/src/collections/Leadership.ts new file mode 100644 index 00000000..a5d8d78f --- /dev/null +++ b/src/collections/Leadership.ts @@ -0,0 +1,235 @@ +import type { CollectionConfig } from 'payload' +import { APIError } from 'payload' + +import { + canCreateLeadership, + canDeleteLeadership, + canManageLeadership, + canManageLeadershipInternal, + canReadLeadership, + canReadLeadershipInternal, + canSetAdministrativeLeadershipField, +} from '@/utilities/campaignAccess' +import { acquirePrimaryContactInvariantLocks } from '@/utilities/primaryContactInvariantLock' +import { relationshipId } from '@/utilities/relationship' + +export const Leadership: CollectionConfig = { + slug: 'leadership', + labels: { + singular: 'Liderança', + plural: 'Lideranças', + }, + admin: { + group: 'Campanha', + useAsTitle: 'contact', + defaultColumns: ['contact', 'nucleus', 'sector', 'supportStatus', 'updatedAt'], + }, + access: { + create: canCreateLeadership, + read: canReadLeadership, + update: canManageLeadership, + delete: canDeleteLeadership, + }, + indexes: [ + { + fields: ['contact', 'nucleus'], + unique: true, + }, + ], + hooks: { + beforeChange: [ + async ({ data, operation, originalDoc, req }) => { + if (operation === 'create' && req.user?.collection === 'campaignUser') { + data.createdBy = req.user.id + } + + if (operation !== 'update') return data + const previousContact = relationshipId(originalDoc?.contact) + const previousNucleus = relationshipId(originalDoc?.nucleus) + const nextContact = relationshipId(data.contact ?? originalDoc?.contact) + const nextNucleus = relationshipId(data.nucleus ?? originalDoc?.nucleus) + const nextStatus = data.supportStatus ?? originalDoc?.supportStatus + if (!previousContact || !previousNucleus || !nextContact || !nextNucleus) return data + + await acquirePrimaryContactInvariantLocks(req, [previousNucleus, nextNucleus]) + const remainsEligible = + nextStatus === 'engajado' && + nextContact === previousContact && + nextNucleus === previousNucleus + if (remainsEligible) return data + + const primaryContactUse = await req.payload.find({ + collection: 'electoralNucleus', + where: { + and: [ + { id: { equals: previousNucleus } }, + { primaryContact: { equals: previousContact } }, + ], + }, + depth: 0, + limit: 1, + overrideAccess: true, + req, + }) + + if (primaryContactUse.totalDocs > 0) { + throw new APIError( + 'Escolha outro contato principal antes de alterar o status desta liderança.', + 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', + required: true, + index: true, + access: { + update: canSetAdministrativeLeadershipField, + }, + }, + { + name: 'sector', + type: 'select', + label: 'Setor', + index: true, + options: [ + { label: 'Religioso', value: 'religioso' }, + { label: 'Sindical', value: 'sindical' }, + { label: 'Comunitário', value: 'comunitario' }, + { label: 'Rural', value: 'rural' }, + { label: 'Empresarial', value: 'empresarial' }, + { label: 'Juventude', value: 'juventude' }, + { label: 'Saúde', value: 'saude' }, + { label: 'Educação', value: 'educacao' }, + { label: 'Cultura', value: 'cultura' }, + { label: 'Outro', value: 'outro' }, + ], + }, + { + name: 'sectorNotes', + type: 'textarea', + label: 'Observações do setor', + maxLength: 1000, + }, + { + name: 'supportStatus', + type: 'select', + label: 'Status de apoio', + required: true, + defaultValue: 'a_abordar', + index: true, + access: { + create: canManageLeadershipInternal, + read: canReadLeadershipInternal, + update: canManageLeadershipInternal, + }, + options: [ + { label: 'Engajado', value: 'engajado' }, + { label: 'A abordar', value: 'a_abordar' }, + { label: 'Em disputa', value: 'em_disputa' }, + { label: 'Negativo', value: 'negativo' }, + ], + }, + { + name: 'user', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Acesso ao app', + index: true, + access: { + create: canSetAdministrativeLeadershipField, + update: canSetAdministrativeLeadershipField, + }, + }, + { + name: 'consent', + type: 'relationship', + relationTo: 'consent', + label: 'Consentimento confirmado', + access: { + create: canSetAdministrativeLeadershipField, + update: canSetAdministrativeLeadershipField, + }, + }, + { + name: 'consentContentHash', + type: 'text', + label: 'Hash do conteúdo consentido', + admin: { + readOnly: true, + }, + access: { + create: canSetAdministrativeLeadershipField, + update: canSetAdministrativeLeadershipField, + }, + }, + { + name: 'consentedAt', + type: 'date', + label: 'Consentimento confirmado em', + admin: { + readOnly: true, + }, + access: { + create: canSetAdministrativeLeadershipField, + update: canSetAdministrativeLeadershipField, + }, + }, + { + name: 'notes', + type: 'textarea', + label: 'Observações internas', + maxLength: 3000, + access: { + create: canManageLeadershipInternal, + read: canReadLeadershipInternal, + update: canManageLeadershipInternal, + }, + }, + { + name: 'consentNote', + type: 'textarea', + label: 'Registro de consentimento externo', + maxLength: 2000, + 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/collections/NucleusUpdate.ts b/src/collections/NucleusUpdate.ts new file mode 100644 index 00000000..561da0f4 --- /dev/null +++ b/src/collections/NucleusUpdate.ts @@ -0,0 +1,264 @@ +import type { + CollectionAfterChangeHook, + CollectionAfterDeleteHook, + CollectionBeforeChangeHook, + CollectionBeforeDeleteHook, + CollectionBeforeValidateHook, + CollectionConfig, + PayloadRequest, +} from 'payload' +import { APIError } from 'payload' + +import { + canCreateNucleusUpdate, + canMutateNucleusUpdate, + canReadNucleusUpdate, + canSetNucleusUpdateAuthor, +} from '@/utilities/campaignAccess' +import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' +import { relationshipId } from '@/utilities/relationship' + +const DERIVED_NUCLEUS_UPDATE_CONTEXT = 'nucleusUpdateDerivedField' + +const nonEmptyText = (value: unknown): boolean => + typeof value === 'string' && value.trim().length > 0 + +const validateNucleusUpdateKind: CollectionBeforeValidateHook = ({ + data, + operation, + originalDoc, +}) => { + if (!data) return data + + const nextData = operation === 'update' ? { ...originalDoc, ...data } : data + const kind = nextData.kind ?? 'semanal' + + if (kind === 'semanal') { + if (!nonEmptyText(nextData.worked)) throw new APIError('Informe o que funcionou.', 400) + if (!nonEmptyText(nextData.failed)) throw new APIError('Informe o que não funcionou.', 400) + if (!nonEmptyText(nextData.needs)) throw new APIError('Informe o que você precisa.', 400) + } else if (!nonEmptyText(nextData.body)) { + throw new APIError('Informe o texto da atualização.', 400) + } + + return data +} + +const uniqueSortedNucleusIDs = (values: Array): number[] => + [...new Set(values.filter((value): value is number => value !== null))].sort( + (left, right) => left - right, + ) + +const acquireNucleusUpdateLocks = (req: PayloadRequest, nucleusIDs: number[]) => + acquireTextAdvisoryLocks( + req.payload, + req, + nucleusIDs.map((nucleusID) => `nucleus-updates:${nucleusID}`), + ) + +const recomputeNucleusLastUpdateAt = async (req: PayloadRequest, nucleusIDs: number[]) => { + for (const nucleusID of nucleusIDs) { + const latest = await req.payload.find({ + collection: 'nucleusUpdate', + where: { nucleus: { equals: nucleusID } }, + depth: 0, + limit: 1, + sort: '-createdAt', + overrideAccess: true, + req, + }) + + await req.payload.update({ + collection: 'electoralNucleus', + id: nucleusID, + data: { lastUpdateAt: latest.docs[0]?.createdAt ?? null }, + depth: 0, + overrideAccess: true, + context: { + [DERIVED_NUCLEUS_UPDATE_CONTEXT]: true, + }, + req, + }) + } +} + +const lockNucleiBeforeChange: CollectionBeforeChangeHook = async ({ + context, + data, + operation, + originalDoc, + req, +}) => { + if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return data + + if (operation === 'create' && req.user?.collection === 'campaignUser') { + data.author = req.user.id + } + + const nucleusIDs = uniqueSortedNucleusIDs([ + relationshipId(originalDoc?.nucleus), + relationshipId(data.nucleus ?? originalDoc?.nucleus), + ]) + if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500) + + await acquireNucleusUpdateLocks(req, nucleusIDs) + return data +} + +const lockNucleusBeforeDelete: CollectionBeforeDeleteHook = async ({ context, id, req }) => { + if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return + + const doc = await req.payload.findByID({ + collection: 'nucleusUpdate', + id, + depth: 0, + overrideAccess: true, + req, + }) + const nucleusIDs = uniqueSortedNucleusIDs([relationshipId(doc.nucleus)]) + if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500) + + await acquireNucleusUpdateLocks(req, nucleusIDs) +} + +const recomputeChangedNuclei: CollectionAfterChangeHook = async ({ + context, + doc, + previousDoc, + req, +}) => { + if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return doc + + const nucleusIDs = uniqueSortedNucleusIDs([ + relationshipId(previousDoc?.nucleus), + relationshipId(doc.nucleus), + ]) + if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500) + + await acquireNucleusUpdateLocks(req, nucleusIDs) + await recomputeNucleusLastUpdateAt(req, nucleusIDs) + return doc +} + +const recomputeDeletedNucleus: CollectionAfterDeleteHook = async ({ context, doc, req }) => { + if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return doc + + const nucleusIDs = uniqueSortedNucleusIDs([relationshipId(doc.nucleus)]) + if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500) + + await acquireNucleusUpdateLocks(req, nucleusIDs) + await recomputeNucleusLastUpdateAt(req, nucleusIDs) + return doc +} + +export const NucleusUpdate: CollectionConfig = { + slug: 'nucleusUpdate', + labels: { + singular: 'Atualização do núcleo', + plural: 'Atualizações dos núcleos', + }, + admin: { + group: 'Campanha', + useAsTitle: 'kind', + defaultColumns: ['nucleus', 'author', 'kind', 'createdAt'], + }, + access: { + create: canCreateNucleusUpdate, + read: canReadNucleusUpdate, + update: canMutateNucleusUpdate, + delete: canMutateNucleusUpdate, + }, + hooks: { + beforeValidate: [validateNucleusUpdateKind], + beforeChange: [lockNucleiBeforeChange], + beforeDelete: [lockNucleusBeforeDelete], + afterChange: [recomputeChangedNuclei], + afterDelete: [recomputeDeletedNucleus], + }, + fields: [ + { + name: 'nucleus', + type: 'relationship', + relationTo: 'electoralNucleus', + label: 'Núcleo eleitoral', + required: true, + index: true, + }, + { + name: 'author', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Autor', + required: true, + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetNucleusUpdateAuthor, + update: canSetNucleusUpdateAuthor, + }, + }, + { + name: 'kind', + type: 'select', + label: 'Tipo', + required: true, + defaultValue: 'semanal', + index: true, + options: [ + { label: 'Semanal', value: 'semanal' }, + { label: 'Urgente', value: 'urgente' }, + { label: 'Nota', value: 'nota' }, + ], + }, + { + name: 'worked', + type: 'textarea', + label: 'O que funcionou', + maxLength: 3000, + admin: { + condition: (_, siblingData) => siblingData.kind === 'semanal', + }, + }, + { + name: 'failed', + type: 'textarea', + label: 'O que não funcionou', + maxLength: 3000, + admin: { + condition: (_, siblingData) => siblingData.kind === 'semanal', + }, + }, + { + name: 'needs', + type: 'textarea', + label: 'O que preciso', + maxLength: 3000, + admin: { + condition: (_, siblingData) => siblingData.kind === 'semanal', + }, + }, + { + name: 'activeVolunteers', + type: 'number', + label: 'Voluntários ativos', + min: 0, + }, + { + name: 'newSupports', + type: 'number', + label: 'Novos apoios', + min: 0, + }, + { + name: 'body', + type: 'textarea', + label: 'Texto', + maxLength: 5000, + admin: { + condition: (_, siblingData) => siblingData.kind !== 'semanal', + }, + }, + ], +} diff --git a/src/components/FormInput.tsx b/src/components/FormInput.tsx index 713fb531..ad6d7888 100644 --- a/src/components/FormInput.tsx +++ b/src/components/FormInput.tsx @@ -1,15 +1,11 @@ 'use client' -import type { - CompositionEventHandler, - FocusEventHandler, - InputEventHandler, - ComponentProps, -} from 'react' -import { Input } from './ui/input' +import type { ComponentProps } from 'react' import { type FieldPath, type FieldValues, useFormContext } from 'react-hook-form' -interface FormInpuProps< +import { FormattedInput } from '@/components/FormattedInput' + +interface FormInputProps< TFieldValues extends FieldValues, TFieldName extends FieldPath, > extends ComponentProps<'input'> { @@ -26,50 +22,20 @@ export const FormInput = < format, sanitize, ...props -}: FormInpuProps) => { +}: FormInputProps) => { const { onChange, onBlur, ...registerResult } = useFormContext().register( name, sanitize ? { setValueAs: sanitize } : undefined, ) - const handleUpdate = (e: EventTarget & HTMLInputElement, value: string) => { - const selectiondistance = e.value.length - (e.selectionEnd ?? 0) - e.value = value - const caretPos = value.length - selectiondistance - e.setSelectionRange(caretPos, caretPos) - } - - const handleInput: InputEventHandler = (e) => { - if (e.nativeEvent.isComposing) { - return - } - - if (format) { - handleUpdate(e.currentTarget, format(e.currentTarget.value)) - } - - onChange(e) - } - - const handleCompositionEnd: CompositionEventHandler | undefined = format - ? (e) => handleUpdate(e.currentTarget, format(e.currentTarget.value)) - : undefined - - const handleBlur: FocusEventHandler = (e) => { - handleUpdate( - e.currentTarget, - format?.(sanitize?.(e.currentTarget.value) ?? e.currentTarget.value) ?? e.currentTarget.value, - ) - onBlur(e) - } - return ( - ) } diff --git a/src/components/FormattedInput.tsx b/src/components/FormattedInput.tsx new file mode 100644 index 00000000..b484eaa1 --- /dev/null +++ b/src/components/FormattedInput.tsx @@ -0,0 +1,60 @@ +'use client' + +import type { + ComponentProps, + CompositionEventHandler, + FocusEventHandler, + InputEventHandler, +} from 'react' + +import { Input } from '@/components/ui/input' + +type FormattedInputProps = ComponentProps<'input'> & { + format?: (value: string) => string + sanitize?: (value: string) => string +} + +const updateInputValue = (input: HTMLInputElement, value: string): void => { + const selectionDistance = input.value.length - (input.selectionEnd ?? input.value.length) + input.value = value + const caretPosition = Math.max(0, value.length - selectionDistance) + input.setSelectionRange(caretPosition, caretPosition) +} + +export const FormattedInput = ({ + format, + sanitize, + onInput, + onCompositionEnd, + onBlur, + ...props +}: FormattedInputProps) => { + const handleInput: InputEventHandler = (event) => { + if (!event.nativeEvent.isComposing && format) { + updateInputValue(event.currentTarget, format(event.currentTarget.value)) + } + onInput?.(event) + } + + const handleCompositionEnd: CompositionEventHandler = (event) => { + if (format) { + updateInputValue(event.currentTarget, format(event.currentTarget.value)) + } + onCompositionEnd?.(event) + } + + const handleBlur: FocusEventHandler = (event) => { + const value = sanitize?.(event.currentTarget.value) ?? event.currentTarget.value + updateInputValue(event.currentTarget, format?.(value) ?? value) + onBlur?.(event) + } + + return ( + + ) +} diff --git a/src/components/PhoneInput.tsx b/src/components/PhoneInput.tsx index fdf2ae0a..b146fa03 100644 --- a/src/components/PhoneInput.tsx +++ b/src/components/PhoneInput.tsx @@ -1,4 +1,7 @@ import type { ComponentProps } from 'react' + +import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone' + import { FormInput } from './FormInput' export const PhoneInput = (props: ComponentProps<'input'>) => ( @@ -9,22 +12,10 @@ export const PhoneInput = (props: ComponentProps<'input'>) => ( autoComplete="tel" inputMode="tel" type="tel" - maxLength={15} + maxLength={19} minLength={15} - format={format} - sanitize={sanitize} + format={formatBrazilianPhoneInput} + sanitize={sanitizeBrazilianPhoneInput} {...props} /> ) - -const sanitize = (value: string) => value.replace(/\D/g, '').slice(0, 11) - -const format = (value: string) => { - const digits = sanitize(value) - - if (!digits) return '' - if (digits.length <= 2) return `(${digits}` - if (digits.length <= 7) return `(${digits.slice(0, 2)}) ${digits.slice(2)}` - - return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7)}` -} diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index e517f196..07b2f5c0 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -1,5 +1,5 @@ import type { Post } from '@/payload-types' -import { Badge } from '@/components/ui/badge' +import { Badge } from '@/components/ui/Badge' import { Card } from '@/components/ui/card' import { formatPostDate, getCategoryName, getPostCanonicalPath } from '@/utilities/posts' import Image from 'next/image' diff --git a/src/components/campaign/ArchiveNucleusDialog.tsx b/src/components/campaign/ArchiveNucleusDialog.tsx new file mode 100644 index 00000000..4c595991 --- /dev/null +++ b/src/components/campaign/ArchiveNucleusDialog.tsx @@ -0,0 +1,60 @@ +'use client' + +import { useActionState } from 'react' +import { ArchiveIcon } from 'lucide-react' + +import { archiveNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/AlertDialog' +import { Button } from '@/components/ui/button' +import { Spinner } from '@/components/ui/Spinner' + +export const ArchiveNucleusDialog = ({ nucleusId }: { nucleusId: number }) => { + const [state, action, pending] = useActionState(archiveNucleusFormAction, {}) + + return ( + + + + + + + Arquivar este núcleo? + + O núcleo sairá das listas ativas. Os dados permanecem preservados no sistema. + + + {state.message ? ( + + Não foi possível arquivar + {state.message} + + ) : null} +
+ + + + Cancelar + + + +
+
+
+ ) +} diff --git a/src/components/campaign/CampaignBottomNav.tsx b/src/components/campaign/CampaignBottomNav.tsx new file mode 100644 index 00000000..c45f26cd --- /dev/null +++ b/src/components/campaign/CampaignBottomNav.tsx @@ -0,0 +1,41 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' + +import { getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav' +import { cn } from '@/lib/utils' +import type { CampaignUser } from '@/payload-types' + +export const CampaignBottomNav = ({ role }: { role: CampaignUser['role'] }) => { + const pathname = usePathname() + + return ( + + ) +} diff --git a/src/components/campaign/CampaignDashboard.tsx b/src/components/campaign/CampaignDashboard.tsx new file mode 100644 index 00000000..34821436 --- /dev/null +++ b/src/components/campaign/CampaignDashboard.tsx @@ -0,0 +1,369 @@ +import { + AlertTriangleIcon, + ArrowRightIcon, + ClipboardListIcon, + MessageCircleIcon, + PlusIcon, + UserRoundPlusIcon, +} from 'lucide-react' +import Link from 'next/link' + +import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' +import { NucleusCard } from '@/components/campaign/NucleusCard' +import { SupportStatusBadge } from '@/components/campaign/SupportStatusBadge' +import { TseZoneBadge } from '@/components/campaign/TseZoneBadge' +import { Badge } from '@/components/ui/Badge' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from '@/components/ui/Empty' +import { Progress } from '@/components/ui/Progress' +import { leadershipSupportStatuses } from '@/lib/schemas/leadership' +import type { + DashboardQueueItem, + GeneralDashboardViewModel, + ScopedDashboardViewModel, +} from '@/utilities/campaignDashboardViewModels' +import { buildLeadershipPanelHref } from '@/utilities/leadershipUi' +import { getCampaignScopeLabel } from '@/utilities/nucleusUi' +import { buildWhatsAppUrl } from '@/utilities/phone' + +const numberFormatter = new Intl.NumberFormat('pt-BR') +const relativeFormatter = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' }) + +const lastUpdateLabel = (value: string | null, now: Date): string => { + if (!value) return 'Nenhuma atualização' + const days = Math.floor((new Date(value).getTime() - now.getTime()) / (24 * 60 * 60 * 1000)) + return `Última atualização ${relativeFormatter.format(days, 'day')}` +} + +export const buildDashboardQueueItemHref = ( + slug: string, + openCoordinatorAssignment: boolean, +): string => `/campanha/nucleos/${slug}${openCoordinatorAssignment ? '?assignCoordinators=1' : ''}` + +const KpiCard = ({ + label, + value, + progress, +}: { + label: string + value: string + progress?: number +}) => ( + + + {label} + {value} + + {progress !== undefined ? ( + + + + ) : null} + +) + +const QueueList = ({ + title, + items, + empty, + openCoordinatorAssignment = false, +}: { + title: string + items: DashboardQueueItem[] + empty: string + openCoordinatorAssignment?: boolean +}) => ( + + + {title} + {items.length} + + + {items.length ? ( +
    + {items.map((item) => ( +
  • + +
  • + ))} +
+ ) : ( +

{empty}

+ )} +
+
+) + +const GeneralDashboard = ({ view }: { view: GeneralDashboardViewModel }) => ( +
+
+
+

Visão geral da campanha

+

Cobertura, mobilização e pendências dos núcleos.

+ + {getCampaignScopeLabel('geral', view.kpis.activeNuclei)} + +
+ +
+ +
+

+ Indicadores da campanha +

+
+ + + + + +
+
+ + + + Lideranças por status de apoio + + + {leadershipSupportStatuses.map((status) => ( +
+ + + {numberFormatter.format(view.supportCounts[status])} + +
+ ))} +
+
+ +
+

+ Filas de ação +

+
+ + + +
+
+
+) + +const CoordinatorDashboard = ({ + view, + now, +}: { + view: Extract + now: Date +}) => ( +
+
+ {getCampaignScopeLabel(view.role, view.cards.length)} +

Sua região

+

O que precisa de atenção nos seus núcleos?

+
+ {view.cards.length ? ( +
+ {view.cards.map((card) => ( + + + + + } + /> + ))} +
+ ) : ( + + + Nenhum núcleo sob sua coordenação + + A coordenação geral ainda não atribuiu um núcleo a você. + + + + )} +
+) + +const LeadershipDashboard = ({ + view, +}: { + view: Extract +}) => ( +
+
+ {getCampaignScopeLabel(view.role, view.cards.length)} +

Meus núcleos

+

+ Envie seu reporte e acompanhe a estimativa confirmada. +

+
+ {view.cards.length ? ( +
+ {view.cards.map((card) => ( + + + {card.name} + + {[card.organization, card.territory].filter(Boolean).join(' · ')} + + + +
+ {card.tseZones.map((zoneNumber) => ( + + ))} +
+

+ + {card.confirmedVoteEstimate === null + ? 'Sem estimativa confirmada' + : `${numberFormatter.format(card.confirmedVoteEstimate)} votos`} + +

+
+

Coordenação

+ {card.coordinators.length ? ( + card.coordinators.map((coordinator) => ( +
+ {coordinator.name} + {coordinator.phone ? ( + + ) : ( + + Contato não disponível + + )} +
+ )) + ) : ( + Coordenação não informada + )} +
+
+ + + +
+ ))} +
+ ) : ( + + + Nenhum núcleo disponível + + Seu acesso depende de um vínculo engajado com o núcleo. + + + + + + )} +
+) + +export const CampaignDashboard = ({ + view, + now, +}: { + view: GeneralDashboardViewModel | ScopedDashboardViewModel + now: Date +}) => { + if (view.role === 'geral') return + if (view.role === 'coordenador') return + return +} diff --git a/src/components/campaign/CampaignInviteForm.tsx b/src/components/campaign/CampaignInviteForm.tsx new file mode 100644 index 00000000..2f640278 --- /dev/null +++ b/src/components/campaign/CampaignInviteForm.tsx @@ -0,0 +1,370 @@ +'use client' + +import { type ReactNode, useActionState } from 'react' +import { CheckCircle2Icon, ShieldCheckIcon } from 'lucide-react' + +import type { + BoundCampaignInviteFormAction, + CampaignInviteFormState, +} from '@/app/(campaign)/campanha/convite/[token]/formActions' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +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 { Textarea } from '@/components/ui/textarea' +import { leadershipGenderLabels, leadershipSectorLabels } from '@/utilities/leadershipUi' + +type CampaignInviteProfile = { + name: string + phone: string + email: string | null + gender: keyof typeof leadershipGenderLabels | null + sector: keyof typeof leadershipSectorLabels | null | undefined + sectorNotes: string | null +} + +const genderOptions = Object.entries(leadershipGenderLabels) as Array< + [NonNullable, string] +> +const sectorOptions = Object.entries(leadershipSectorLabels) as Array< + [NonNullable, string] +> + +const firstError = (state: CampaignInviteFormState, field: string) => + state.fieldErrors?.[field]?.[0] + +const CampaignInviteFeedback = ({ state }: { state: CampaignInviteFormState }) => { + if (!state.message || state.status === 'success') return null + return ( + + Não foi possível confirmar + {state.message} + + ) +} + +const ConsentField = ({ + children, + error, +}: { + children: ReactNode + error?: string +}) => ( +
+
+ +

+ Leia o texto e confirme somente se estiver de acordo.{' '} + + Ler o texto completo + +

+
+ + + + + + Li e concordo com o texto de consentimento * + + + Você confirma os próprios dados enviados neste formulário. + + {error ? {error} : null} + + +
+) + +const ProfileFields = ({ + preview, + state, +}: { + preview: { profile: CampaignInviteProfile } + state: CampaignInviteFormState +}) => { + const { profile } = preview + return ( + + + Nome * + + {firstError(state, 'name') ? ( + {firstError(state, 'name')} + ) : null} + + + + Celular (WhatsApp) * + + {firstError(state, 'phone') ? ( + {firstError(state, 'phone')} + ) : null} + + + + E-mail (opcional) + + {firstError(state, 'email') ? ( + {firstError(state, 'email')} + ) : null} + + +
+ + Gênero + + Não informar + {genderOptions.map(([value, label]) => ( + + {label} + + ))} + + {firstError(state, 'gender') ? ( + {firstError(state, 'gender')} + ) : null} + + + + Setor + + Não informar + {sectorOptions.map(([value, label]) => ( + + {label} + + ))} + + {firstError(state, 'sector') ? ( + {firstError(state, 'sector')} + ) : null} + +
+ + + + Como você atua nesse setor? (opcional) + +