mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
Deliver Onda 0 MVP: provisional Consent, privacy policy, and /privacidade.
Auto-provision legal texts via migrations and db:seed:consent so local and production deploy stay fail-closed-ready; register O0+ simplify follow-ups.
This commit is contained in:
parent
ced1b71771
commit
cc66ec3c31
24 changed files with 10039 additions and 46 deletions
|
|
@ -18,7 +18,7 @@ alwaysApply: false
|
|||
- **C7 registrado (2026-07-18):** débitos de escala/DRY pós-C3 do `/simplify` (território/contato compostos, helpers FormData/URL, leituras por aba, RMW/índice) — plano [`docs/plans/escala-dry-pos-c3.md`](../../../docs/plans/escala-dry-pos-c3.md); não bloqueia o MVP da agenda.
|
||||
- **Trilha E registrada (2026-07-19) — mapa de projeção por município:** plano E1–E5 para equiparar e superar as planilhas "Mapa de projeção de votos Solla 2026" (`docs/sheets/`): E1 metas em cenários (`voteGoals` Bom/Regular/Mínimo) + `priority` no núcleo (migration compartilhada com E3), E2 série histórica TSE 2014/2018 + `computeVoteTrend` derivado (sem migration), E3 `dobradinhaNotes`/`nextSteps` staff-only na inteligência, E4 import único `pnpm db:seed:mapa` (sem criar `Contact`/`leadership`), E5 Salvador por bairro registrado como futuro. Somente documentação/plano — nada implementado. Plano: [`docs/plans/mapa-projecao-municipios.md`](../../../docs/plans/mapa-projecao-municipios.md).
|
||||
- Fluxos operacionais cobertos: login isolado, CRUD/arquivo de núcleos por slug, designação de coordenador (`geral`), CRM de lideranças, estimativas sugerir/confirmar com versão UUID, feed de atualizações, convites WhatsApp (`wa.me` + hash de uso único), dashboard com agregados só de núcleos ativos.
|
||||
- Pendência operacional (não de código): Consent `lideranca-autopreenchimento` + lote jurídico LGPD antes de dados reais; smoke pós-deploy Vercel. Ver Onda 0 em [`docs/roadmap.md`](../../../docs/roadmap.md).
|
||||
- Pendência operacional (não de código): textos provisórios Onda 0 provisionados por migration ([`docs/plans/onda-0.md`](../../../docs/plans/onda-0.md)); substituição pela assessoria jurídica eleitoral + hold de PII real de lideranças/apoiadores até lá; smoke pós-deploy Vercel. Ver Onda 0 em [`docs/roadmap.md`](../../../docs/roadmap.md).
|
||||
- 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.
|
||||
|
|
@ -107,6 +107,6 @@ alwaysApply: false
|
|||
|
||||
## 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.
|
||||
- **Bloqueador obrigatório de produção (PII real):** antes de lideranças/apoiadores reais ou import em massa, a assessoria jurídica eleitoral deve aprovar os textos definitivos (substituindo os provisórios do [Onda 0 MVP](../../../docs/plans/onda-0.md)). Os textos provisórios já sobem automaticamente via migration; o hold é de **titulares reais**, não de engenharia.
|
||||
- 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).
|
||||
|
|
|
|||
117
docs/plans/escala-dry-pos-onda0.md
Normal file
117
docs/plans/escala-dry-pos-onda0.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Escala e DRY pós-Onda 0 (Consent / privacidade / cache)
|
||||
|
||||
Status: registrado no roadmap (fases pendentes)
|
||||
Atualizado em: 2026-07-19
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Onda 0+, fill-in de engenharia pós-MVP)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
A Onda 0 ([onda-0.md](onda-0.md)) entregou textos provisórios de `Consent`, global `privacy-policy`, rota `/privacidade`, migrations `20260719_054706_*` / `20260719_054707_*`, CLI `pnpm db:seed:consent` e duas passagens `/simplify` (2026-07-19). O cleanup aplicado cobriu: migration de dados só via SQL, constantes `ONDA0_CONSENT_ENTRIES` / `ONDA0_PRIVACY_POLICY_BODY`, batch `find` no provision Payload, `DELETE … IN` no down SQL, memoização de `getCachedGlobal`, singleton em `/privacidade`, resolução de conflito no roadmap.
|
||||
|
||||
Os três revisores do `/simplify` (quality / performance / reuse) deixaram débitos **maiores que cleanup pontual**, registrados aqui para não se perderem entre deploy e revisão jurídica.
|
||||
|
||||
1. **Cache de globals após writes fora do runtime Payload.** `provisionOnda0ConsentAndPrivacyDb` (deploy) e `seed-consent.mjs` com `skipRevalidation` não disparam `revalidateTag('global_privacy-policy')`. `/privacidade` pode servir `published`/body stale até TTL ou até um edit no admin. O endpoint `POST /api/revalidate` só busta `posts`.
|
||||
2. **Chaves de Consent duplicadas.** `lideranca-autopreenchimento` já vive em `campaignInvite.ts`; `apoiador-*` em `campaignConsent.ts` (`server-only`). `onda0ConsentTexts.ts` ainda repete literais (só a chave de liderança foi unificada). `campanha-notificacoes-push` só existe em Onda 0 até D2.
|
||||
3. **Cobertura de testes incompleta no caminho de produção.** Int tests exercitam `provisionOnda0ConsentAndPrivacy` (Payload API); o deploy roda `provisionOnda0ConsentAndPrivacyDb`. Reconciliation chama o down do seed mas não asserta estado de `privacy_policy` / ausência das chaves.
|
||||
4. **DRY de superfícies públicas Lexical.** `/privacidade` e artigos (`/[type]/[category]/[slug]`) convertem Lexical→HTML com padrões CSS distintos (`prose` vs `[&_…]`). Layout editorial (`SiteHeader` + `Footer`) duplica `(home)/layout.tsx` sem shell compartilhado.
|
||||
5. **UX do link de privacidade.** `Footer` sempre mostra `/privacidade`; com `published: false` a rota 404a (mesmo padrão da ficha de apoiador antes do seed).
|
||||
6. **Documentação redundante.** `scripts/consent-texts/README.md` só aponta para `onda0ConsentTexts.ts` e `onda-0.md`.
|
||||
|
||||
**Já resolvido no simplify (não reabrir):** tags em `unstable_cache` para globals; factory memoizada por `(slug, depth)`; duplicate fetch metadata+page em `/privacidade`; N+1 no provision Payload; batch DELETE no down SQL; migration schema sem `payload`/`req` mortos.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Deploy e `pnpm db:seed:consent` deixam `/privacidade` e globals cacheados consistentes sem exigir edit manual no admin.
|
||||
- Uma fonte de verdade para literais de chave `Consent` testável em unit tests (sem `server-only` no módulo de chaves).
|
||||
- Int tests cobrem o caminho SQL/migration que produção usa.
|
||||
- Reduzir duplicação de layout e renderização Lexical no site público quando surgir a próxima página institucional (`Pages`).
|
||||
- Guardrails: sem migration obrigatória; sem alterar textos jurídicos; hold de PII real permanece até assessoria.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Um plano O0+, quatro fases ordenadas.** Mesmo racional de A7/B5/C10: um registro no roadmap, PRs por fase. Ordem: revalidate/cache (correção operacional) → chaves Consent (DRY) → testes SQL → DRY UI público (só quando `Pages` ou segunda página institucional justificar).
|
||||
- **Dependência suave da Onda 0 engenharia ✓.** Não bloqueia lote jurídico nem smoke com dados fictícios; bloqueia confiança em cache pós-deploy e paridade de testes.
|
||||
- **Módulo de chaves sem `server-only`.** `src/lib/consentKeys.ts` (ou equivalente) importável por `onda0ConsentTexts.ts` (unit) e re-exportado por `campaignConsent.ts` / `campaignInvite.ts` — não o inverso.
|
||||
- **Revalidate de globals no mesmo espírito de `posts`.** Estender `src/app/(frontend)/api/revalidate/route.ts` com tag opcional ou lista fixa de globals públicos; documentar no runbook Onda 0.
|
||||
- **Cortável:** Fase 4 (Lexical/layout shell) até existir `Pages` ou bio/propostas; Fase 5 (Footer condicional) é barata mas opcional se o seed sempre publica em prod.
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês (`consentKeys`, `revalidateGlobalTag`), strings visíveis em pt-BR inalteradas.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Revalidate automático no fim da migration vs endpoint manual?** **Recomendação:** endpoint primeiro (paridade com `posts` seed); hook pós-migrate no build só se o time esquecer o passo em produção — documentar curl no [onda-0.md](onda-0.md).
|
||||
- **Unificar seed CLI no caminho SQL?** **Recomendação:** manter Payload no CLI (valida hooks/admin) mas chamar `revalidateGlobal('privacy-policy')` ao final; migration continua SQL-only.
|
||||
- **Extrair `LexicalProse` agora ou com `Pages`?** **Recomendação:** adiar Fase 4 até o primeiro consumidor além de `/privacidade` + artigo — evitar abstração de uma página.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
O0["Onda 0 MVP ✓"] --> F1
|
||||
F1["Fase 1 — revalidate globals<br/>(API + runbook)"]
|
||||
F1 --> F2["Fase 2 — consentKeys.ts<br/>(DRY literais)"]
|
||||
F2 --> F3["Fase 3 — int tests SQL/migration"]
|
||||
F3 --> F4["Fase 4 — Lexical + EditorialLayout<br/>(quando Pages)"]
|
||||
F3 --> F5["Fase 5 — Footer link condicional"]
|
||||
```
|
||||
|
||||
### Fase 1 — Cache de globals pós-migration/seed
|
||||
|
||||
- Estender `src/app/(frontend)/api/revalidate/route.ts`: aceitar `tag` query/body (`global_privacy-policy`, lista allowlist) ou segundo secret header; manter default `posts` para compatibilidade.
|
||||
- Após `provisionOnda0ConsentAndPrivacy` em `scripts/seed-consent.mjs`, chamar `revalidateGlobal('privacy-policy')` via Local API helper ou fetch local ao endpoint (mesmo padrão documentado para post-seed).
|
||||
- Atualizar checklist em [onda-0.md](onda-0.md) § Smoke: `curl` pós-deploy quando migration de dados rodar sem edit admin.
|
||||
- Opcional: helper `revalidatePublicGlobals()` em `src/utilities/globals.ts` com tags conhecidas (`privacy-policy`, futuro `metadata`).
|
||||
|
||||
### Fase 2 — Chaves Consent únicas
|
||||
|
||||
- Novo `src/lib/consentKeys.ts` exportando `CAMPAIGN_INVITE_CONSENT_KEY`, `SUPPORTER_REGISTRATION_CONSENT_KEY`, `SUPPORTER_VOTE_INTENTION_CONSENT_KEY`, `CAMPAIGN_PUSH_CONSENT_KEY` (constantes literais).
|
||||
- `campaignInvite.ts` / `campaignConsent.ts` importam e re-exportam (compatibilidade).
|
||||
- `onda0ConsentTexts.ts` monta `ONDA0_CONSENT_KEYS` / `ONDA0_CONSENT_KEY_LIST` a partir do módulo.
|
||||
- Quando D2 iniciar, `get/requireCampaignPushConsent` usa `CAMPAIGN_PUSH_CONSENT_KEY` do mesmo módulo.
|
||||
|
||||
### Fase 3 — Testes do caminho SQL
|
||||
|
||||
- Int spec: `provisionOnda0ConsentAndPrivacyDb` + `removeOnda0ConsentAndPrivacyDb` contra `teqo_test`, ou invocar migration `054707` up/down diretamente.
|
||||
- Assertar: quatro chaves presentes após up; `privacy_policy.published === true`; após down chaves removidas e `published === false`.
|
||||
- Em `campaignMigrationReconciliation.int.spec.ts`: após `onda0Seed.down`, assertar ausência das chaves Onda 0 (opcional: count `privacy_policy`).
|
||||
|
||||
### Fase 4 — DRY site público Lexical/layout _(adiar até `Pages`)_
|
||||
|
||||
- Componente `LexicalArticle` ou reutilizar classes do artigo de post em `/privacidade`.
|
||||
- `EditorialPageLayout` extraindo `(home)/layout.tsx` e `privacidade/layout.tsx`.
|
||||
- `unstable_cache` wrapper para HTML convertido keyed por `updatedAt` + tag global.
|
||||
|
||||
### Fase 5 — Footer e metadata _(opcional)_
|
||||
|
||||
- `Footer` server component: `getCachedGlobal('privacy-policy')` depth 0, `select`/campo `published` — esconder link se false.
|
||||
- `generateMetadata` de `/privacidade`: sufixo `| ${metadata.title}` do global `metadata` (cosmético).
|
||||
|
||||
**Migration:** nenhuma nas fases 1–5.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Dura:** Onda 0 engenharia entregue (migrations + `/privacidade` + provision utilities).
|
||||
- **Suave:** collection `Pages` (AGENTS Known Gap #3) para justificar Fase 4; D2 para `requireCampaignPushConsent`.
|
||||
- Reusa: `getCachedGlobal` / `revalidateGlobal` (`src/utilities/globals.ts`), `provisionOnda0ConsentAndPrivacyDb` (`src/utilities/onda0Provision.ts`), padrão `POST /api/revalidate` de posts.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Substituir textos provisórios por versão jurídica final — continua no lote Onda 0 / assessoria.
|
||||
- Migrar fluxos públicos legados (`consent: 2` em `submitWhatsapp.ts`) — AGENTS Known Gap #2; plano separado.
|
||||
- RBAC admin `users` — Known Gap #1.
|
||||
- Links Lexical clicáveis em `/privacidade` no corpo do consent footer (`/privacidade` como texto plano é aceitável no MVP).
|
||||
- Unificar provision Payload e SQL num único caminho — dual path é intencional (migration sem hooks vs CLI com Payload).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` — § Onda 0, Fill-ins, Site público
|
||||
- `docs/plans/onda-0.md` — runbook e provisionamento
|
||||
- `src/lib/onda0ConsentTexts.ts` — textos canônicos
|
||||
- `src/utilities/onda0Provision.ts` — SQL vs Payload
|
||||
- `src/utilities/globals.ts` — cache de globals
|
||||
- `src/app/(frontend)/api/revalidate/route.ts` — revalidate posts
|
||||
- `src/app/(frontend)/privacidade/page.tsx` — página pública
|
||||
- `src/components/Footer.tsx` — link privacidade
|
||||
- `src/utilities/campaignConsent.ts` / `campaignInvite.ts` — chaves runtime
|
||||
- `tests/int/onda0Provision.int.spec.ts` / `campaignMigrationReconciliation.int.spec.ts`
|
||||
- AGENTS.md — Consent por chave estável, `revalidateGlobal`, naming
|
||||
23
docs/plans/onda-0-ripd-nota-mvp.md
Normal file
23
docs/plans/onda-0-ripd-nota-mvp.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Onda 0 — nota interna RIPD (MVP)
|
||||
|
||||
Status: rascunho operacional (não substitui RIPD formal)
|
||||
Atualizado em: 2026-07-19
|
||||
Relacionado: [onda-0.md](onda-0.md)
|
||||
|
||||
## Quando um RIPD formal será necessário
|
||||
|
||||
A assessoria jurídica eleitoral deve avaliar a necessidade de Relatório de Impacto à Proteção de Dados Pessoais (RIPD) quando a campanha passar a tratar em **larga escala**:
|
||||
|
||||
- cadastro nominal de apoiadores com dados de contato;
|
||||
- **intenção de voto** (dado sensível — art. 11 LGPD);
|
||||
- eventual cruzamento territorial / inteligência eleitoral.
|
||||
|
||||
## O que este MVP não faz
|
||||
|
||||
- Não produz o RIPD.
|
||||
- Não substitui parecer da assessoria eleitoral.
|
||||
- Não autoriza coleta de titulares reais — apenas textos provisórios para destravar a plataforma.
|
||||
|
||||
## Próximo passo jurídico
|
||||
|
||||
Após aprovação dos textos definitivos de Consent e da Política de Privacidade, a assessoria deve confirmar se RIPD é obrigatório antes da primeira importação em massa ou captura sistemática de intenção de voto.
|
||||
110
docs/plans/onda-0.md
Normal file
110
docs/plans/onda-0.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Onda 0 — go-live MVP (Consent + privacidade)
|
||||
|
||||
Status: implementado (engenharia)
|
||||
Atualizado em: 2026-07-19
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Onda 0 — Caminho crítico para `/campanha` em produção)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
O MVP de Núcleos e o cadastro nominal de apoiadores (C2) estão em `main` e falham fechado sem as chaves estáveis de `Consent` e sem o Aviso de Privacidade publicado. A assessoria jurídica eleitoral ainda não entregou os textos finais; para desbloquear engenharia, QA e smoke operacional, a Onda 0 provisiona **textos provisórios** automaticamente em local e produção via migrations, com hold explícito de PII real até revisão jurídica.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Provisionar automaticamente (local + prod via `pnpm migrate` / build Vercel) as quatro chaves de `Consent` e o Global `privacy-policy` publicado.
|
||||
- Expor `/privacidade` no site público e corrigir links mortos (Footer, ficha de apoiador).
|
||||
- Manter fail-closed do runtime; não cadastrar lideranças/apoiadores **reais** antes da assessoria eleitoral substituir os textos.
|
||||
- Oferecer `pnpm db:seed:consent` como espelho idempotente para re-aplicar textos após edição no repo.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Assessoria provisória no MVP (2026-07-19).** A implementação redige textos utilizáveis em pt-BR com banner de status provisório. Não substituem parecer jurídico nem RIPD formal.
|
||||
- **Provisionamento automático.** Migration de schema (`privacy-policy` global) + migration de dados (`seed_onda0_consent_and_privacy`) aplicadas no deploy; sem passo manual de colar no admin.
|
||||
- **Hold de PII real.** Não importar CSV real, não convidar lideranças reais, não cadastrar apoiadores reais até substituição dos textos pela assessoria eleitoral.
|
||||
- **Fonte canônica dos textos:** [`src/lib/onda0ConsentTexts.ts`](../../src/lib/onda0ConsentTexts.ts) + [`src/utilities/onda0Provision.ts`](../../src/utilities/onda0Provision.ts).
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`privacy-policy`, `provisionOnda0ConsentAndPrivacy`); strings visíveis em pt-BR.
|
||||
|
||||
## Chaves de Consent
|
||||
|
||||
| Chave | Uso |
|
||||
| ----------------------------- | ------------------------------------------------------ |
|
||||
| `lideranca-autopreenchimento` | Convites / autopreenchimento de liderança |
|
||||
| `apoiador-cadastro` | Cadastro nominal de apoiador |
|
||||
| `apoiador-intencao-voto` | Intenção de voto (dado sensível — art. 11 LGPD) |
|
||||
| `campanha-notificacoes-push` | Opt-in push (D2; chave provisionada, UI push pendente) |
|
||||
|
||||
## Provisionamento
|
||||
|
||||
### Automático (local + produção)
|
||||
|
||||
1. `20260719_054706_add_privacy_policy_global` — schema do Global.
|
||||
2. `20260719_054707_seed_onda0_consent_and_privacy` — upsert Consent ×4 + `privacy-policy` (`published: true`).
|
||||
|
||||
Aplicado por `pnpm migrate` localmente e por `payload migrate` no `pnpm build` da Vercel.
|
||||
|
||||
### CLI espelho (re-seed)
|
||||
|
||||
```bash
|
||||
pnpm db:seed:consent
|
||||
# Neon (somente re-seed consciente pós-revisão jurídica):
|
||||
ALLOW_REMOTE_DB=true pnpm db:seed:consent
|
||||
```
|
||||
|
||||
Refusa host não-local sem `ALLOW_REMOTE_DB=true` (mesma família de guard que `db:seed:tse`).
|
||||
|
||||
## Substituição pelos textos finais
|
||||
|
||||
1. Assessoria eleitoral aprova textos definitivos.
|
||||
2. Atualizar [`src/lib/onda0ConsentTexts.ts`](../../src/lib/onda0ConsentTexts.ts).
|
||||
3. Nova migration de dados **ou** `ALLOW_REMOTE_DB=true pnpm db:seed:consent` em produção.
|
||||
4. Aceites com `contentHash` antigo exigem novo checkbox (comportamento existente).
|
||||
5. Só então liberar coleta de titulares reais (lideranças, apoiadores, import CSV).
|
||||
|
||||
## Smoke (dados fictícios apenas)
|
||||
|
||||
**Local (PR):**
|
||||
|
||||
1. `pnpm migrate` (ou `db:seed:consent` se iterando textos).
|
||||
2. Login `/campanha` → criar núcleo de teste.
|
||||
3. Convite com Consent visível (contato fictício).
|
||||
4. Apoiador de teste (+ intenção de voto opcional).
|
||||
5. GET `/privacidade` com banner provisório.
|
||||
|
||||
**Produção (pós-deploy):**
|
||||
|
||||
1. Confirmar env: `NEXT_PUBLIC_SITE_URL` HTTPS, `PAYLOAD_SECRET`, `DATABASE_URL`, `BLOB_*`.
|
||||
2. Migrations aplicadas no build.
|
||||
3. Smoke com **dados fictícios**; não importar CSV real nem convidar titulares reais.
|
||||
|
||||
## Onboarding staff (sem PII de campo)
|
||||
|
||||
- Criar `campaignUser` `geral`/`coordenador` via `/admin`.
|
||||
- Estruturar núcleos de teste, agenda, baseline TSE.
|
||||
- Treinamento de campo com titulares reais **após** revisão jurídica.
|
||||
|
||||
## RIPD
|
||||
|
||||
Ver [onda-0-ripd-nota-mvp.md](onda-0-ripd-nota-mvp.md) — nota interna; não substitui RIPD formal da assessoria.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- D2 push / VAPID.
|
||||
- Known Gap #2 (Consent por ID nos fluxos públicos).
|
||||
- Collection `Pages`.
|
||||
- RBAC em `users`.
|
||||
- Seed de lideranças/apoiadores reais.
|
||||
|
||||
## Referências
|
||||
|
||||
- [`docs/roadmap.md`](../roadmap.md) § Onda 0
|
||||
- [`docs/plans/escala-dry-pos-onda0.md`](escala-dry-pos-onda0.md) — débitos técnicos pós-`/simplify` (revalidate globals, chaves Consent, testes SQL)
|
||||
- [`docs/plans/cadastro-nominal-apoiadores.md`](cadastro-nominal-apoiadores.md)
|
||||
- [`docs/plans/notifications.md`](notifications.md)
|
||||
- [`src/collections/Consent.ts`](../../src/collections/Consent.ts)
|
||||
- [`src/globals/PrivacyPolicy.ts`](../../src/globals/PrivacyPolicy.ts)
|
||||
- AGENTS.md — Consent por chave, fail-closed, checklist deploy
|
||||
|
||||
## Revisões
|
||||
|
||||
- **2026-07-19:** Plano criado na implementação Onda 0 — textos provisórios MVP, provisionamento automático local+prod, hold de PII real.
|
||||
- **2026-07-19:** Débitos do `/simplify` registrados em [escala-dry-pos-onda0.md](escala-dry-pos-onda0.md) (O0+).
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 parcial 2026-07-19 (FormData território, contadores lista, locks, contactSearchQuery) — débitos pós-simplify registrados no plano; C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md))
|
||||
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 parcial 2026-07-19 (FormData território, contadores lista, locks, contactSearchQuery) — débitos pós-simplify registrados no plano; C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md))
|
||||
|
||||
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ Registro canônico no repositório dos planos futuros e débitos conhecidos. Sta
|
|||
|
||||
O MVP de Núcleos está **entregue e deployado** (ondas 1–8 + refactors; 2026-07-18), junto com A1 (território multi-município/bairro), A2 (zonas TSE + sugestões cruzadas — sem migration), A3 (baseline TSE 2022 — modelo + import), A4 (baseline no produto + Gap vs 2022 — mesclado em `main`), B1 (overview da lista), C1 (compartilhar página), C3 (planos de ação / agenda) e D1 (PWA). C2 (cadastro nominal de apoiadores) tem a engenharia pronta e mesclada em `main`, mas segue bloqueada em produção pelo lote jurídico (Consent keys). As migrations (`20260718_010733_consolidate_campaign_schema`, `20260718_190559_territorio_multi_municipio_bairro`, `20260718_195854_add_election_results`, `20260718_222656_add_supporter`, `20260718_222832_add_action_plan`, `20260719_011015_add_supporter_import_batch`) aplicam automaticamente no `pnpm build` da Vercel. O que ainda separa a vertical de uso operacional com dados reais é jurídico + smoke pós-deploy:
|
||||
|
||||
1. **Lote jurídico único de LGPD/Consent** _(externo — assessoria jurídica eleitoral; é o caminho crítico da vertical inteira)_. Uma única rodada cobrindo:
|
||||
1. **Lote jurídico único de LGPD/Consent** _(externo — assessoria jurídica eleitoral; textos finais substituem os provisórios do [Onda 0 MVP](plans/onda-0.md); PII real de lideranças/apoiadores permanece em hold até lá)_. Uma única rodada cobrindo:
|
||||
- Base do art. 11 da LGPD + texto versionado de `Consent.key = 'lideranca-autopreenchimento'` (bloqueador do MVP de Núcleos; o app falha fechado sem a chave).
|
||||
- Textos de `apoiador-cadastro` e `apoiador-intencao-voto` (bloqueadores do cadastro nominal — [detalhes](plans/cadastro-nominal-apoiadores.md)).
|
||||
- Texto de `campanha-notificacoes-push` (opt-in de push — [detalhes](plans/notifications.md)).
|
||||
|
|
@ -42,6 +42,8 @@ O MVP de Núcleos está **entregue e deployado** (ondas 1–8 + refactors; 2026-
|
|||
3. **Ativação com dados reais assim que (1) liberar a chave de liderança.** (A decisão de 2026-07-17 de não esperar a migração multi-município ficou superada: A1 foi entregue e deployado antes da ativação — coordenadores já estruturam núcleos reais durante as convenções, 20/07–05/08, com o território definitivo.)
|
||||
4. **Onboarding do time real** (usuários `geral`/`coordenador`, primeiros núcleos, treinamento básico de campo).
|
||||
|
||||
**O0+ Escala e DRY pós-Onda 0 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: revalidate de globals pós-migration/seed, módulo único de chaves `Consent`, testes do caminho SQL, DRY Lexical/layout no site público (adiar até `Pages`), link condicional no Footer. Não bloqueia lote jurídico nem smoke com dados fictícios. [Plano](plans/escala-dry-pos-onda0.md).
|
||||
|
||||
## Campanha (`/campanha`)
|
||||
|
||||
### Ciclo 1 — Núcleos (entregue)
|
||||
|
|
@ -110,6 +112,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
|
|||
| C8 Escala e DRY pós-C6 | — (sem design novo; reusa os de C2/C6) | [escala-dry-pos-c6.md](plans/escala-dry-pos-c6.md) |
|
||||
| C9 Escala e DRY pós-C8 | — (sem design novo; reusa os de C2/C6/C8) | [escala-dry-pos-c8.md](plans/escala-dry-pos-c8.md) |
|
||||
| C10 Escala e DRY pós-C9 | — (sem design novo; reusa os de C2/C6/C8/C9) | [escala-dry-pos-c9.md](plans/escala-dry-pos-c9.md) |
|
||||
| O0+ Escala e DRY pós-Onda 0 | — (sem design novo; reusa `/privacidade` + padrão revalidate `posts`) | [escala-dry-pos-onda0.md](plans/escala-dry-pos-onda0.md) |
|
||||
| B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) |
|
||||
| D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) |
|
||||
| D1 PWA `/campanha` | `Notificacoes-PWA` (banner "Instale o app da campanha") | [pwa-campanha.md](plans/pwa-campanha.md) |
|
||||
|
|
@ -126,6 +129,7 @@ flowchart TD
|
|||
subgraph Onda0["Onda 0 — produção"]
|
||||
JUR["Lote jurídico único<br/>(4 Consent keys + aviso privacidade)"]
|
||||
DEPLOY["Checklist deploy + lançamento MVP"]
|
||||
O0DRY["O0+ Escala e DRY pós-Onda 0<br/>(cache / chaves / testes SQL)"]
|
||||
end
|
||||
|
||||
subgraph TrilhaA["Trilha A — dados eleitorais e território"]
|
||||
|
|
@ -203,6 +207,7 @@ flowchart TD
|
|||
C9 --> C10
|
||||
D1 --> D2
|
||||
JUR -.chave de push.-> D2
|
||||
DEPLOY -.engenharia entregue.-> O0DRY
|
||||
A4 --> E2
|
||||
E1 --> E4
|
||||
E3 --> E4
|
||||
|
|
@ -213,7 +218,7 @@ flowchart TD
|
|||
A7 -.mesmo loader do baseline.-> E2
|
||||
```
|
||||
|
||||
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3 e fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C7 (após C3; suave: C6 F2 ✓), C10 (após C9 ✓; cortável se a base nominal permanecer pequena) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import).
|
||||
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓) e fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C7 (após C3; suave: C6 F2 ✓), C10 (após C9 ✓; cortável se a base nominal permanecer pequena) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import).
|
||||
|
||||
### Sequência de execução por janela do calendário
|
||||
|
||||
|
|
@ -221,7 +226,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3 e fil
|
|||
|
||||
| Ordem | Item | Plano | Depende de | Paralelizável com |
|
||||
| ----- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------- | ----------------- |
|
||||
| 1 | Onda 0 (jurídico em lote + deploy + lançamento) | — | externo | tudo |
|
||||
| 1 | Onda 0 (textos MVP auto + deploy + hold PII real) | [detalhes](plans/onda-0.md) | engenharia entregue; jurídico final | tudo |
|
||||
| 2 | A1 Território multi-município/bairro **(entregue 2026-07-18)** | [detalhes](plans/territorio-multi-municipio-bairro.md) | — | B1, C1, A3 |
|
||||
| 3 | A2 Zonas TSE + sugestões cruzadas (chips opt-in território↔ZE) **(entregue 2026-07-18)** | [detalhes](plans/zonas-por-municipio.md) | A1 (nasce contra `cities[]`) | B1 ✓, C1 ✓, A3 ✓ |
|
||||
| 4 | B1 Overview da lista de núcleos **(entregue 2026-07-18)** | [detalhes](plans/overview-lista-nucleos.md) | — | A1, A2, C1 |
|
||||
|
|
@ -230,18 +235,18 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3 e fil
|
|||
|
||||
**Janela 2 — 05/08 → 16/08 (pré-propaganda): base nominal + inteligência + agenda prontas para o arranque.**
|
||||
|
||||
| Ordem | Item | Plano | Depende de | Paralelizável com |
|
||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ---------------------------------- | --------------------- |
|
||||
| 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta e mesclada em `main` 2026-07-18 — deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3, C6, C7 |
|
||||
| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–5 + simplify)** | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3, C7, C8 |
|
||||
| 9 | A4 Baseline no produto + insight Gap vs 2022 **(implementado e mesclado em `main` 2026-07-18 — Fases 2–4 + simplify)** | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7, C8 |
|
||||
| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7, C8 |
|
||||
| 11 | C7 Escala e DRY pós-C3 _(parcial 2026-07-19: FormData território, contadores lista, locks, `contactSearchQuery`; pendente: composição territorial, shell de contato, leituras por aba, short-circuit de hooks)_ | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8, E1+E3, E2 |
|
||||
| 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7, E1+E3, E2, C9 |
|
||||
| Ordem | Item | Plano | Depende de | Paralelizável com |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ---------------------------------- | -------------------------- |
|
||||
| 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta e mesclada em `main` 2026-07-18 — deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3, C6, C7 |
|
||||
| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–5 + simplify)** | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3, C7, C8 |
|
||||
| 9 | A4 Baseline no produto + insight Gap vs 2022 **(implementado e mesclado em `main` 2026-07-18 — Fases 2–4 + simplify)** | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7, C8 |
|
||||
| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7, C8 |
|
||||
| 11 | C7 Escala e DRY pós-C3 _(parcial 2026-07-19: FormData território, contadores lista, locks, `contactSearchQuery`; pendente: composição territorial, shell de contato, leituras por aba, short-circuit de hooks)_ | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8, E1+E3, E2 |
|
||||
| 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7, E1+E3, E2, C9 |
|
||||
| 13 | C9 Escala e DRY pós-C8 (filtro unificado Payload↔SQL, `contactSearchQuery` no aggregate, loader único `/apoiadores`, forms restantes) **(implementado 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c8.md) | C8 (merge) | A4, C3, C7, E1+E3, E2, C10 |
|
||||
| 14 | C10 Escala e DRY pós-C9 (dedup access lista, scope único de núcleos do coordenador, `errorProps` nos forms) _(cortável se a base nominal permanecer pequena; Fase 3 DRY pode entrar isolada)_ | [detalhes](plans/escala-dry-pos-c9.md) | C9 (merge) | A4, C3, C7, E1+E3, E2 |
|
||||
| 15 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela |
|
||||
| 16 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, C7, C8, C9, C10 |
|
||||
| 14 | C10 Escala e DRY pós-C9 (dedup access lista, scope único de núcleos do coordenador, `errorProps` nos forms) _(cortável se a base nominal permanecer pequena; Fase 3 DRY pode entrar isolada)_ | [detalhes](plans/escala-dry-pos-c9.md) | C9 (merge) | A4, C3, C7, E1+E3, E2 |
|
||||
| 15 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela |
|
||||
| 16 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, C7, C8, C9, C10 |
|
||||
|
||||
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
|
||||
|
||||
|
|
@ -267,12 +272,13 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3 e fil
|
|||
|
||||
**Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):**
|
||||
|
||||
- **O0+ Escala e DRY pós-Onda 0** (revalidate globals, chaves Consent, testes SQL provision, DRY Lexical/layout) — engenharia pós-MVP; paralelizável com smoke Onda 0. → [detalhes](plans/escala-dry-pos-onda0.md)
|
||||
- **Visitados recentemente** (client-side, `localStorage`). → [detalhes](plans/visitados-recentemente.md)
|
||||
- **Listas globais** (lideranças, atualizações, territórios no nível raiz). _(plano MVP; sem plano detalhado ainda)_
|
||||
- **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_
|
||||
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
|
||||
|
||||
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 restantes (leituras por aba / short-circuit de hooks / feed O(n) — só se a agenda permanecer pequena; contadores + locks já entregues), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2, C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
|
||||
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 restantes (leituras por aba / short-circuit de hooks / feed O(n) — só se a agenda permanecer pequena; contadores + locks já entregues), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2, C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), **O0+ Fase 1** (revalidate globals pós-migration) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
|
||||
|
||||
### Itens consolidados/removidos nesta revisão (2026-07-17)
|
||||
|
||||
|
|
@ -285,20 +291,20 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3 e fil
|
|||
|
||||
## Bloqueadores atuais
|
||||
|
||||
| Item | Status | Fonte |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------- |
|
||||
| Lote jurídico único LGPD (4 textos de `Consent` + Aviso de Privacidade + avaliação de RIPD) — ver Onda 0. Código do MVP já em `main`; o app falha fechado sem as chaves | **caminho crítico; iniciar já** | notebook Núcleos; AGENTS checklist; planos cadastro-nominal e notifications |
|
||||
| RBAC em `users` — todo usuário do admin Payload tem acesso total; necessário antes de abrir `/admin` a equipe maior. Nota: o import de apoiadores foi desenhado no `/campanha` justamente para não depender disso | pendente | AGENTS Known Gap #1 |
|
||||
| Fluxos públicos ainda hardcodam ID de Consent (ex. `consent: 2` em `submitWhatsapp.ts`); migrar para chave estável como na campanha | pendente | AGENTS Known Gap #2; plano-arquitetura |
|
||||
| Collection `Pages` inexistente; hero/copy da home ainda hardcoded — bio, propostas e páginas institucionais | pendente (não bloqueia `/campanha`) | AGENTS Known Gap #3 |
|
||||
| Item | Status | Fonte |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------- |
|
||||
| Lote jurídico único LGPD (4 textos de `Consent` + Aviso de Privacidade + avaliação de RIPD) — textos **provisórios MVP** já provisionados por migration ([onda-0.md](plans/onda-0.md)); substituição final + liberação de PII real aguarda assessoria | **caminho crítico para dados reais** | notebook Núcleos; AGENTS checklist; planos cadastro-nominal, notifications e onda-0 |
|
||||
| RBAC em `users` — todo usuário do admin Payload tem acesso total; necessário antes de abrir `/admin` a equipe maior. Nota: o import de apoiadores foi desenhado no `/campanha` justamente para não depender disso | pendente | AGENTS Known Gap #1 |
|
||||
| Fluxos públicos ainda hardcodam ID de Consent (ex. `consent: 2` em `submitWhatsapp.ts`); migrar para chave estável como na campanha | pendente | AGENTS Known Gap #2; plano-arquitetura |
|
||||
| Collection `Pages` inexistente; hero/copy da home ainda hardcoded — bio, propostas e páginas institucionais | pendente (não bloqueia `/campanha`) | AGENTS Known Gap #3 |
|
||||
|
||||
## Site público
|
||||
|
||||
**Já entregue:** sistema de notícias/publicações (`post`/`tag`), listagens e artigos, seed a partir de jorgesolla.com.br, cache com tag `posts`.
|
||||
**Já entregue:** sistema de notícias/publicações (`post`/`tag`), listagens e artigos, seed a partir de jorgesolla.com.br, cache com tag `posts`; **página `/privacidade`** via global `privacy-policy` (texto provisório Onda 0 — revisão jurídica pendente).
|
||||
|
||||
**Próximos** (não bloqueiam `/campanha`, exceto a política de privacidade, que entra no lote jurídico da Onda 0):
|
||||
|
||||
- Página de política de privacidade institucional (LGPD) — **prioridade alta**, sai do lote jurídico da Onda 0. _(plano-arquitetura §3.1)_
|
||||
- Página de política de privacidade institucional (LGPD) — **MVP entregue** (`/privacidade`); textos finais e polish pós-simplify (revalidate globals, DRY Lexical) em [escala-dry-pos-onda0.md](plans/escala-dry-pos-onda0.md). _(plano-arquitetura §3.1)_
|
||||
- Modelar e popular `Pages` para conteúdo institucional (biografia, mandato, propostas). _(AGENTS Known Gap #3; plano-arquitetura §3)_
|
||||
- Tornar editáveis título/subtítulo (e demais textos) da home via global/`Pages`. _(AGENTS Known Gap #3)_
|
||||
- Agenda pública e multimídia: preferir links oficiais (Câmara, YouTube, Flickr). _(plano-arquitetura §3.1)_
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"db:pull": "node scripts/db-pull.mjs",
|
||||
"db:seed:posts": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-posts.mjs",
|
||||
"db:seed:tse": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-tse-results.mjs",
|
||||
"db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs",
|
||||
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
|
||||
"dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
|
|
|
|||
10
scripts/consent-texts/README.md
Normal file
10
scripts/consent-texts/README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Consent texts (Onda 0)
|
||||
|
||||
Canonical source: [`src/lib/onda0ConsentTexts.ts`](../../src/lib/onda0ConsentTexts.ts).
|
||||
|
||||
Provisioning runs via:
|
||||
|
||||
- Migration `20260719_054707_seed_onda0_consent_and_privacy` (local + produção no deploy)
|
||||
- CLI `pnpm db:seed:consent` (re-aplicar após editar os textos)
|
||||
|
||||
Keys: `lideranca-autopreenchimento`, `apoiador-cadastro`, `apoiador-intencao-voto`, `campanha-notificacoes-push`, plus Global `privacy-policy`.
|
||||
40
scripts/seed-consent.mjs
Normal file
40
scripts/seed-consent.mjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Re-applies Onda 0 provisional Consent keys and privacy-policy global.
|
||||
* Idempotent upsert — same logic as migration 20260719_054707_seed_onda0_consent_and_privacy.
|
||||
*
|
||||
* Safety: refuses non-local DATABASE_URL unless ALLOW_REMOTE_DB=true.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm db:seed:consent
|
||||
* ALLOW_REMOTE_DB=true pnpm db:seed:consent
|
||||
*/
|
||||
|
||||
import { config as loadEnv } from 'dotenv'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { assertLocalDatabase } from './assert-local-database.mjs'
|
||||
|
||||
loadEnv({ path: '.env.local' })
|
||||
loadEnv({ path: '.env' })
|
||||
|
||||
assertLocalDatabase(
|
||||
'seed:consent',
|
||||
'This script upserts provisional Consent/privacy texts. Use migrations for prod deploy.',
|
||||
)
|
||||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
const { provisionOnda0ConsentAndPrivacy } = await import('../src/utilities/onda0Provision.ts')
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
try {
|
||||
await provisionOnda0ConsentAndPrivacy(payload)
|
||||
console.log('[seed:consent] OK — provisional Consent keys and privacy-policy global updated.')
|
||||
} catch (error) {
|
||||
console.error(`\n[seed:consent] ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
if (typeof payload.db?.destroy === 'function') {
|
||||
await payload.db.destroy()
|
||||
}
|
||||
}
|
||||
13
src/app/(frontend)/privacidade/layout.tsx
Normal file
13
src/app/(frontend)/privacidade/layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Footer } from '@/components/Footer'
|
||||
import { SiteHeader } from '@/components/SiteHeader'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export default function PrivacyLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div data-theme="editorial" className="flex min-h-dvh w-full flex-col">
|
||||
<SiteHeader />
|
||||
<div className="flex-1">{children}</div>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
src/app/(frontend)/privacidade/page.tsx
Normal file
41
src/app/(frontend)/privacidade/page.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { getCachedGlobal } from '@/utilities/globals'
|
||||
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
const getCachedPrivacyPolicy = getCachedGlobal('privacy-policy')
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const policy = await getCachedPrivacyPolicy()
|
||||
if (!policy.published) return {}
|
||||
|
||||
return {
|
||||
title: 'Política de Privacidade | Jorge Solla',
|
||||
description:
|
||||
'Aviso de Privacidade e tratamento de dados pessoais da campanha Jorge Solla (plataforma Teqo).',
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PrivacyPolicyPage() {
|
||||
const policy = await getCachedPrivacyPolicy()
|
||||
|
||||
if (!policy.published) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const html = convertLexicalToHTML({ data: policy.body })
|
||||
|
||||
return (
|
||||
<main className="w-full bg-background text-foreground">
|
||||
<article className="mx-auto w-full max-w-3xl px-4 py-12 sm:px-6 lg:px-8 lg:py-16">
|
||||
<h1 className="mb-8 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Política de Privacidade
|
||||
</h1>
|
||||
<div
|
||||
className="prose prose-neutral max-w-none leading-relaxed [&_a]:text-primary [&_a]:underline-offset-4 [&_a]:hover:underline [&_p]:mb-4"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</article>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { SocialLinks } from './socialLinks'
|
||||
|
||||
export const Footer = () => (
|
||||
<footer className="flex items-center justify-center p-4">
|
||||
<footer className="flex flex-col items-center justify-center gap-3 p-4 text-center text-sm text-muted-foreground">
|
||||
<SocialLinks ariaLabel="Redes sociais do rodapé" />
|
||||
<Link href="/privacidade" className="text-primary underline-offset-4 hover:underline">
|
||||
Política de Privacidade
|
||||
</Link>
|
||||
</footer>
|
||||
)
|
||||
|
|
|
|||
43
src/globals/PrivacyPolicy.ts
Normal file
43
src/globals/PrivacyPolicy.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { revalidateGlobal } from '@/utilities/globals'
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
const slug = 'privacy-policy'
|
||||
|
||||
const revalidate = async ({ context }: { context?: { skipRevalidation?: boolean } }) => {
|
||||
if (context?.skipRevalidation) return
|
||||
await revalidateGlobal(slug)
|
||||
}
|
||||
|
||||
export const PrivacyPolicy: GlobalConfig = {
|
||||
slug,
|
||||
label: 'Política de Privacidade',
|
||||
admin: {
|
||||
group: 'Configurações',
|
||||
description:
|
||||
'Aviso de Privacidade institucional exibido em /privacidade. Publicar somente após revisão jurídica.',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [revalidate],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'published',
|
||||
type: 'checkbox',
|
||||
label: 'Publicado',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'Quando desmarcado, a rota pública /privacidade retorna 404.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
type: 'richText',
|
||||
label: 'Texto',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
105
src/lib/onda0ConsentTexts.ts
Normal file
105
src/lib/onda0ConsentTexts.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type { Consent } from '@/payload-types'
|
||||
import { CAMPAIGN_INVITE_CONSENT_KEY } from '@/utilities/campaignInvite'
|
||||
|
||||
export const ONDA0_PROVISIONAL_BANNER =
|
||||
'Texto provisório para MVP e testes internos da plataforma Teqo (campanha Jorge Solla). Não constitui parecer jurídico. Deve ser substituído por texto aprovado pela assessoria jurídica eleitoral antes de coleta de dados de titulares reais em produção.'
|
||||
|
||||
export const ONDA0_CONSENT_KEYS = {
|
||||
leadershipAutofill: CAMPAIGN_INVITE_CONSENT_KEY,
|
||||
supporterRegistration: 'apoiador-cadastro',
|
||||
supporterVoteIntention: 'apoiador-intencao-voto',
|
||||
campaignPush: 'campanha-notificacoes-push',
|
||||
} as const
|
||||
|
||||
export type Onda0ConsentKey = (typeof ONDA0_CONSENT_KEYS)[keyof typeof ONDA0_CONSENT_KEYS]
|
||||
|
||||
export const ONDA0_CONSENT_KEY_LIST: Onda0ConsentKey[] = [
|
||||
ONDA0_CONSENT_KEYS.leadershipAutofill,
|
||||
ONDA0_CONSENT_KEYS.supporterRegistration,
|
||||
ONDA0_CONSENT_KEYS.supporterVoteIntention,
|
||||
ONDA0_CONSENT_KEYS.campaignPush,
|
||||
]
|
||||
|
||||
type LexicalRoot = NonNullable<Consent['text']>
|
||||
|
||||
const paragraph = (text: string) => ({
|
||||
type: 'paragraph' as const,
|
||||
children: [{ type: 'text' as const, text, version: 1 as const }],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1 as const,
|
||||
})
|
||||
|
||||
const buildLexicalFromParagraphs = (paragraphs: string[]): LexicalRoot => ({
|
||||
root: {
|
||||
type: 'root',
|
||||
children: paragraphs.map(paragraph),
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const contactChannel =
|
||||
'Para exercer seus direitos previstos no art. 18 da LGPD (acesso, correção, eliminação, portabilidade, revogação do consentimento), entre em contato pelo e-mail privacidade@jorgesolla.com.br.'
|
||||
|
||||
const consentFooter = `Mais informações: ${contactChannel} Consulte também a Política de Privacidade em /privacidade.`
|
||||
|
||||
const consentDefinitions: Record<Onda0ConsentKey, string[]> = {
|
||||
[ONDA0_CONSENT_KEYS.leadershipAutofill]: [
|
||||
ONDA0_PROVISIONAL_BANNER,
|
||||
'Finalidade: registrar e atualizar seus dados de contato (nome, telefone, e-mail, setor) para participação como liderança de campanha vinculada a um núcleo eleitoral, incluindo acesso à área interna /campanha quando aplicável.',
|
||||
'Dados tratados: nome, telefone, e-mail, setor e vínculo com o núcleo. Não coletamos dados sensíveis neste fluxo.',
|
||||
'Base legal: consentimento do titular (art. 7º, I, e art. 8º da Lei nº 13.709/2018 — LGPD).',
|
||||
'Compartilhamento: equipe autorizada da campanha e prestadores de hospedagem/processamento necessários à operação da plataforma, sem comercialização dos dados.',
|
||||
'Prazo de retenção: enquanto durar o vínculo de liderança e pelos prazos legais aplicáveis após o encerramento.',
|
||||
consentFooter,
|
||||
],
|
||||
[ONDA0_CONSENT_KEYS.supporterRegistration]: [
|
||||
ONDA0_PROVISIONAL_BANNER,
|
||||
'Finalidade: registrar seu apoio declarado à campanha de Jorge Solla, permitindo contato organizado pela equipe de campanha e eventual vinculação a um núcleo eleitoral.',
|
||||
'Dados tratados: nome, telefone, e-mail (quando informado), município/território e observações operacionais inseridas pela equipe.',
|
||||
'Base legal: consentimento do titular (art. 7º, I, e art. 8º da LGPD).',
|
||||
'Compartilhamento: equipe autorizada da campanha; não há venda ou cessão a terceiros para fins comerciais.',
|
||||
'Prazo de retenção: durante a campanha e pelos prazos legais aplicáveis; você pode solicitar eliminação conforme a Política de Privacidade.',
|
||||
consentFooter,
|
||||
],
|
||||
[ONDA0_CONSENT_KEYS.supporterVoteIntention]: [
|
||||
ONDA0_PROVISIONAL_BANNER,
|
||||
'Atenção: este consentimento é destacado e separado do cadastro de apoio. A intenção de voto é dado pessoal sensível nos termos do art. 11 da LGPD.',
|
||||
'Finalidade: registrar, de forma opcional e separada, sua declaração de intenção de voto relativa à candidatura de Jorge Solla, para planejamento interno da campanha.',
|
||||
'Dados tratados: categoria de intenção de voto selecionada (por exemplo: certo, tende a certo, indeciso, outro).',
|
||||
'Base legal: consentimento específico e destacado do titular para dado sensível (art. 11, I, da LGPD).',
|
||||
'Você pode recusar ou revogar este consentimento sem prejuízo do cadastro de apoio, quando o cadastro base já existir.',
|
||||
consentFooter,
|
||||
],
|
||||
[ONDA0_CONSENT_KEYS.campaignPush]: [
|
||||
ONDA0_PROVISIONAL_BANNER,
|
||||
'Finalidade: enviar notificações push no aplicativo instalável da área /campanha (alertas operacionais, lembretes de agenda e atualizações da coordenação).',
|
||||
'Dados tratados: identificador técnico de inscrição push no dispositivo e preferências de notificação; não inclui conteúdo de mensagens de terceiros.',
|
||||
'Base legal: consentimento do titular (art. 7º, I, e art. 8º da LGPD).',
|
||||
'Você pode revogar o consentimento e desativar notificações nas configurações do dispositivo ou do app.',
|
||||
consentFooter,
|
||||
],
|
||||
}
|
||||
|
||||
export const ONDA0_CONSENT_ENTRIES: Array<{ key: Onda0ConsentKey; text: LexicalRoot }> =
|
||||
ONDA0_CONSENT_KEY_LIST.map((key) => ({
|
||||
key,
|
||||
text: buildLexicalFromParagraphs(consentDefinitions[key]),
|
||||
}))
|
||||
|
||||
export const ONDA0_PRIVACY_POLICY_BODY: LexicalRoot = buildLexicalFromParagraphs([
|
||||
ONDA0_PROVISIONAL_BANNER,
|
||||
'Esta Política de Privacidade descreve como a campanha de Jorge Solla (controlador) trata dados pessoais por meio do site público e da ferramenta interna /campanha (plataforma Teqo).',
|
||||
'Dados que podemos tratar: identificação e contato (nome, telefone, e-mail), dados territoriais de apoio, registros de consentimento, dados operacionais de campanha e, quando separadamente consentido, intenção de voto (dado sensível — art. 11 da LGPD).',
|
||||
'Finalidades: organização de núcleos eleitorais, cadastro de apoiadores, comunicação com lideranças e equipe, operação de agenda e inteligência eleitoral interna, sempre conforme a finalidade informada no momento da coleta.',
|
||||
'Bases legais: consentimento (arts. 7º e 8º da LGPD) e, para dado sensível de intenção de voto, consentimento específico e destacado (art. 11, I).',
|
||||
'Compartilhamento: não vendemos dados. Prestadores de tecnologia (hospedagem, banco de dados) atuam como operadores sob nossas instruções.',
|
||||
'Direitos do titular (art. 18 LGPD): confirmação de tratamento, acesso, correção, anonimização, portabilidade, eliminação e revogação de consentimento, mediante solicitação.',
|
||||
contactChannel,
|
||||
'Segurança: adotamos medidas técnicas e administrativas proporcionais ao risco, incluindo controle de acesso na ferramenta /campanha.',
|
||||
'Atualizações: esta política pode ser revisada; a versão publicada nesta página prevalece. Textos de consentimento específicos são versionados e registrados no aceite.',
|
||||
])
|
||||
9215
src/migrations/20260719_054706_add_privacy_policy_global.json
Normal file
9215
src/migrations/20260719_054706_add_privacy_policy_global.json
Normal file
File diff suppressed because it is too large
Load diff
18
src/migrations/20260719_054706_add_privacy_policy_global.ts
Normal file
18
src/migrations/20260719_054706_add_privacy_policy_global.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "privacy_policy" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"published" boolean DEFAULT false,
|
||||
"body" jsonb NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone,
|
||||
"created_at" timestamp(3) with time zone
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP TABLE "privacy_policy" CASCADE;`)
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import type { MigrateDownArgs, MigrateUpArgs } from '@payloadcms/db-postgres'
|
||||
|
||||
import {
|
||||
provisionOnda0ConsentAndPrivacyDb,
|
||||
removeOnda0ConsentAndPrivacyDb,
|
||||
} from '@/utilities/onda0Provision'
|
||||
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await provisionOnda0ConsentAndPrivacyDb(db)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await removeOnda0ConsentAndPrivacyDb(db)
|
||||
}
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
import * as migration_20260715_163458_initial from './20260715_163458_initial';
|
||||
import * as migration_20260715_163500_consent_text_to_jsonb from './20260715_163500_consent_text_to_jsonb';
|
||||
import * as migration_20260715_181058_add_post_and_tag from './20260715_181058_add_post_and_tag';
|
||||
import * as migration_20260715_215834_rename_tag_visible_to_hidden from './20260715_215834_rename_tag_visible_to_hidden';
|
||||
import * as migration_20260716_010420_add_campaign_user from './20260716_010420_add_campaign_user';
|
||||
import * as migration_20260718_010733_consolidate_campaign_schema from './20260718_010733_consolidate_campaign_schema';
|
||||
import * as migration_20260718_190559_territorio_multi_municipio_bairro from './20260718_190559_territorio_multi_municipio_bairro';
|
||||
import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results';
|
||||
import * as migration_20260718_222656_add_supporter from './20260718_222656_add_supporter';
|
||||
import * as migration_20260718_222832_add_action_plan from './20260718_222832_add_action_plan';
|
||||
import * as migration_20260719_011015_add_supporter_import_batch from './20260719_011015_add_supporter_import_batch';
|
||||
import * as migration_20260719_014906_action_plan_list_perf from './20260719_014906_action_plan_list_perf';
|
||||
import * as migration_20260719_020000_add_contact_trgm_index from './20260719_020000_add_contact_trgm_index';
|
||||
import * as migration_20260715_163458_initial from './20260715_163458_initial'
|
||||
import * as migration_20260715_163500_consent_text_to_jsonb from './20260715_163500_consent_text_to_jsonb'
|
||||
import * as migration_20260715_181058_add_post_and_tag from './20260715_181058_add_post_and_tag'
|
||||
import * as migration_20260715_215834_rename_tag_visible_to_hidden from './20260715_215834_rename_tag_visible_to_hidden'
|
||||
import * as migration_20260716_010420_add_campaign_user from './20260716_010420_add_campaign_user'
|
||||
import * as migration_20260718_010733_consolidate_campaign_schema from './20260718_010733_consolidate_campaign_schema'
|
||||
import * as migration_20260718_190559_territorio_multi_municipio_bairro from './20260718_190559_territorio_multi_municipio_bairro'
|
||||
import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results'
|
||||
import * as migration_20260718_222656_add_supporter from './20260718_222656_add_supporter'
|
||||
import * as migration_20260718_222832_add_action_plan from './20260718_222832_add_action_plan'
|
||||
import * as migration_20260719_011015_add_supporter_import_batch from './20260719_011015_add_supporter_import_batch'
|
||||
import * as migration_20260719_014906_action_plan_list_perf from './20260719_014906_action_plan_list_perf'
|
||||
import * as migration_20260719_020000_add_contact_trgm_index from './20260719_020000_add_contact_trgm_index'
|
||||
import * as migration_20260719_054706_add_privacy_policy_global from './20260719_054706_add_privacy_policy_global'
|
||||
import * as migration_20260719_054707_seed_onda0_consent_and_privacy from './20260719_054707_seed_onda0_consent_and_privacy'
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -78,4 +80,14 @@ export const migrations = [
|
|||
down: migration_20260719_020000_add_contact_trgm_index.down,
|
||||
name: '20260719_020000_add_contact_trgm_index',
|
||||
},
|
||||
];
|
||||
{
|
||||
up: migration_20260719_054706_add_privacy_policy_global.up,
|
||||
down: migration_20260719_054706_add_privacy_policy_global.down,
|
||||
name: '20260719_054706_add_privacy_policy_global',
|
||||
},
|
||||
{
|
||||
up: migration_20260719_054707_seed_onda0_consent_and_privacy.up,
|
||||
down: migration_20260719_054707_seed_onda0_consent_and_privacy.down,
|
||||
name: '20260719_054707_seed_onda0_consent_and_privacy',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -130,11 +130,13 @@ export interface Config {
|
|||
'site-settings': SiteSetting;
|
||||
home: Home;
|
||||
metadata: Metadatum;
|
||||
'privacy-policy': PrivacyPolicy;
|
||||
};
|
||||
globalsSelect: {
|
||||
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
|
||||
home: HomeSelect<false> | HomeSelect<true>;
|
||||
metadata: MetadataSelect<false> | MetadataSelect<true>;
|
||||
'privacy-policy': PrivacyPolicySelect<false> | PrivacyPolicySelect<true>;
|
||||
};
|
||||
locale: null;
|
||||
widgets: {
|
||||
|
|
@ -1607,6 +1609,36 @@ export interface Metadatum {
|
|||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* Aviso de Privacidade institucional exibido em /privacidade. Publicar somente após revisão jurídica.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "privacy-policy".
|
||||
*/
|
||||
export interface PrivacyPolicy {
|
||||
id: number;
|
||||
/**
|
||||
* Quando desmarcado, a rota pública /privacidade retorna 404.
|
||||
*/
|
||||
published?: boolean | null;
|
||||
body: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "site-settings_select".
|
||||
|
|
@ -1668,6 +1700,17 @@ export interface MetadataSelect<T extends boolean = true> {
|
|||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "privacy-policy_select".
|
||||
*/
|
||||
export interface PrivacyPolicySelect<T extends boolean = true> {
|
||||
published?: T;
|
||||
body?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "collections_widget".
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { Tag } from './collections/Tag'
|
|||
import { Users } from './collections/Users'
|
||||
import { HomePage } from './globals/HomePage'
|
||||
import { Metadata } from './globals/Metadata'
|
||||
import { PrivacyPolicy } from './globals/PrivacyPolicy'
|
||||
import { SiteSettings } from './globals/SiteSettings'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
|
|
@ -67,7 +68,7 @@ export default buildConfig({
|
|||
Post,
|
||||
Tag,
|
||||
],
|
||||
globals: [SiteSettings, HomePage, Metadata],
|
||||
globals: [SiteSettings, HomePage, Metadata, PrivacyPolicy],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
typescript: {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,17 @@ export async function getGlobal<Slug extends Global>(slug: Slug, depth?: number)
|
|||
|
||||
const getTag = (slug: string) => `global_${slug}`
|
||||
|
||||
export const getCachedGlobal = <Slug extends Global>(slug: Slug, depth?: number) =>
|
||||
unstable_cache(async () => getGlobal(slug, depth), [getTag(slug)])
|
||||
const cachedGlobalFactories = new Map<string, ReturnType<typeof unstable_cache>>()
|
||||
|
||||
export const getCachedGlobal = <Slug extends Global>(slug: Slug, depth?: number) => {
|
||||
const tag = getTag(slug)
|
||||
const cacheKey = `${tag}:${depth ?? 0}`
|
||||
let factory = cachedGlobalFactories.get(cacheKey)
|
||||
if (!factory) {
|
||||
factory = unstable_cache(async () => getGlobal(slug, depth), [cacheKey], { tags: [tag] })
|
||||
cachedGlobalFactories.set(cacheKey, factory)
|
||||
}
|
||||
return factory
|
||||
}
|
||||
|
||||
export const revalidateGlobal = <Slug extends Global>(slug: Slug) => revalidateTag(getTag(slug))
|
||||
|
|
|
|||
84
src/utilities/onda0Provision.ts
Normal file
84
src/utilities/onda0Provision.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { MigrateUpArgs } from '@payloadcms/db-postgres'
|
||||
import { sql } from '@payloadcms/db-postgres'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import {
|
||||
ONDA0_CONSENT_ENTRIES,
|
||||
ONDA0_CONSENT_KEY_LIST,
|
||||
ONDA0_PRIVACY_POLICY_BODY,
|
||||
} from '@/lib/onda0ConsentTexts'
|
||||
|
||||
type Db = MigrateUpArgs['db']
|
||||
|
||||
export const provisionOnda0ConsentAndPrivacyDb = async (db: Db): Promise<void> => {
|
||||
for (const { key, text } of ONDA0_CONSENT_ENTRIES) {
|
||||
const textJson = JSON.stringify(text)
|
||||
await db.execute(sql`
|
||||
INSERT INTO "consent" ("key", "text", "created_at", "updated_at")
|
||||
VALUES (${key}, ${textJson}::jsonb, now(), now())
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"text" = EXCLUDED."text",
|
||||
"updated_at" = now();
|
||||
`)
|
||||
}
|
||||
|
||||
const bodyJson = JSON.stringify(ONDA0_PRIVACY_POLICY_BODY)
|
||||
await db.execute(sql`DELETE FROM "privacy_policy";`)
|
||||
await db.execute(sql`
|
||||
INSERT INTO "privacy_policy" ("published", "body", "created_at", "updated_at")
|
||||
VALUES (true, ${bodyJson}::jsonb, now(), now());
|
||||
`)
|
||||
}
|
||||
|
||||
export const removeOnda0ConsentAndPrivacyDb = async (db: Db): Promise<void> => {
|
||||
await db.execute(sql`
|
||||
DELETE FROM "consent" WHERE "key" IN (${sql.join(
|
||||
ONDA0_CONSENT_KEY_LIST.map((key) => sql`${key}`),
|
||||
sql`, `,
|
||||
)});
|
||||
`)
|
||||
await db.execute(sql`UPDATE "privacy_policy" SET "published" = false, "updated_at" = now();`)
|
||||
}
|
||||
|
||||
export const provisionOnda0ConsentAndPrivacy = async (payload: Payload): Promise<void> => {
|
||||
const existing = await payload.find({
|
||||
collection: 'consent',
|
||||
where: { key: { in: [...ONDA0_CONSENT_KEY_LIST] } },
|
||||
depth: 0,
|
||||
limit: ONDA0_CONSENT_KEY_LIST.length,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const byKey = new Map(existing.docs.map((doc) => [doc.key, doc]))
|
||||
|
||||
for (const { key, text } of ONDA0_CONSENT_ENTRIES) {
|
||||
const doc = byKey.get(key)
|
||||
if (doc) {
|
||||
await payload.update({
|
||||
collection: 'consent',
|
||||
id: doc.id,
|
||||
data: { text },
|
||||
overrideAccess: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: 'consent',
|
||||
data: { key, text },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
|
||||
await payload.updateGlobal({
|
||||
slug: 'privacy-policy',
|
||||
data: {
|
||||
body: ONDA0_PRIVACY_POLICY_BODY,
|
||||
published: true,
|
||||
},
|
||||
overrideAccess: true,
|
||||
context: { skipRevalidation: true },
|
||||
})
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { drizzle } from '@payloadcms/db-postgres/drizzle/node-postgres'
|
||||
import { sql } from '@payloadcms/db-postgres'
|
||||
import { drizzle } from '@payloadcms/db-postgres/drizzle/node-postgres'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { migrations } from '@/migrations'
|
||||
|
|
@ -162,7 +162,14 @@ describe('campaign migration existing-schema reconciliation', () => {
|
|||
const original = migrations.find(
|
||||
(migration) => migration.name === '20260716_010420_add_campaign_user',
|
||||
)
|
||||
if (!consolidated || !original) throw new Error('Campaign migrations are not registered.')
|
||||
const onda0Seed = migrations.find(
|
||||
(migration) => migration.name === '20260719_054707_seed_onda0_consent_and_privacy',
|
||||
)
|
||||
if (!consolidated || !original || !onda0Seed) {
|
||||
throw new Error('Campaign migrations are not registered.')
|
||||
}
|
||||
|
||||
await onda0Seed.down({ db: database } as never)
|
||||
|
||||
await consolidated.down({ db: database } as never)
|
||||
await original.down({ db: database } as never)
|
||||
|
|
|
|||
46
tests/int/onda0Provision.int.spec.ts
Normal file
46
tests/int/onda0Provision.int.spec.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { getPayload, type Payload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { ONDA0_CONSENT_ENTRIES } from '@/lib/onda0ConsentTexts'
|
||||
import config from '@/payload.config'
|
||||
import { requireConsentByKey } from '@/utilities/campaignConsent'
|
||||
import { provisionOnda0ConsentAndPrivacy } from '@/utilities/onda0Provision'
|
||||
|
||||
import { assertTestDatabase } from '../helpers/assertTestDatabase'
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
|
||||
installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
describe('Onda 0 provision (integration)', () => {
|
||||
beforeAll(async () => {
|
||||
assertTestDatabase(process.env.DATABASE_URL)
|
||||
payload = await getPayload({ config })
|
||||
})
|
||||
|
||||
it('upserts consent keys and publishes privacy-policy idempotently', async () => {
|
||||
await provisionOnda0ConsentAndPrivacy(payload)
|
||||
await provisionOnda0ConsentAndPrivacy(payload)
|
||||
|
||||
for (const { key } of ONDA0_CONSENT_ENTRIES) {
|
||||
const descriptor = await requireConsentByKey(payload, key)
|
||||
expect(descriptor.key).toBe(key)
|
||||
expect(descriptor.contentHash).toMatch(/^[a-f0-9]{64}$/)
|
||||
}
|
||||
|
||||
const privacy = await payload.findGlobal({
|
||||
slug: 'privacy-policy',
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(privacy.published).toBe(true)
|
||||
expect(privacy.body).toBeTruthy()
|
||||
})
|
||||
})
|
||||
29
tests/unit/onda0ConsentTexts.unit.spec.ts
Normal file
29
tests/unit/onda0ConsentTexts.unit.spec.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ONDA0_CONSENT_ENTRIES,
|
||||
ONDA0_CONSENT_KEY_LIST,
|
||||
ONDA0_PRIVACY_POLICY_BODY,
|
||||
ONDA0_PROVISIONAL_BANNER,
|
||||
} from '@/lib/onda0ConsentTexts'
|
||||
|
||||
describe('onda0ConsentTexts', () => {
|
||||
it('includes the provisional banner in every consent and privacy body', () => {
|
||||
for (const { text } of ONDA0_CONSENT_ENTRIES) {
|
||||
expect(text.root.children[0]?.children[0]).toMatchObject({ text: ONDA0_PROVISIONAL_BANNER })
|
||||
}
|
||||
expect(ONDA0_PRIVACY_POLICY_BODY.root.children[0]?.children[0]).toMatchObject({
|
||||
text: ONDA0_PROVISIONAL_BANNER,
|
||||
})
|
||||
})
|
||||
|
||||
it('defines all four stable keys', () => {
|
||||
expect(ONDA0_CONSENT_ENTRIES.map((entry) => entry.key)).toEqual(ONDA0_CONSENT_KEY_LIST)
|
||||
})
|
||||
|
||||
it('builds multi-paragraph lexical roots for each consent', () => {
|
||||
for (const { text } of ONDA0_CONSENT_ENTRIES) {
|
||||
expect(text.root.children.length).toBeGreaterThan(3)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue