merge(OPS75): convergência Forgejo→GitHub — GitHub como fonte única
* deploy: limpeza pós-deploy do build cache e tags locais antigas (INF3/F2) O deploy compila no homeserver e acumulava build cache + tags locais antigas no disco raiz (incidente 19/08: 45G de cache). Após o smoke: docker builder prune -f + remoção das tags locais teqo-1313 exceto o SHA em uso (best-effort, registry preserva o rollback). * feat(S9): captura de novidades da campanha na home pública (form + CTA do hero) Seção 'Receba as novidades da campanha' acima do rodapé: nome + WhatsApp obrigatórios, e-mail/estado/cidade/comentário opcionais, toggle pré-selecionado 'Quero fazer parte do time' gravado como subscription.campaignLevel (time|esporadico). CTA secundário do hero vira o atalho (#novidades, scroll suave). Contact + Subscription em withPayloadTransaction com consentimento novo fail-closed por chave estável 'campanha-novidades' — nada é gravado sem o texto aprovado no admin. Migration 20260819_192605 (state de contact nullable + coluna campaign_level). Impl plan, changelog, int + e2e. * plans(C141,C143,C142): permissões granulares por assessor + responsáveis de demanda + UI por perfil * plans(C141,C143,C142): permissões granulares por assessor + responsáveis de demanda + UI por perfil * feat(S8): seção 'Nossa história' na home — vídeo da trajetória embutido do YouTube (Closes #91) * docs(S8): resolução do conflito com S9 (história → newsletter) + nota do guard e2e dev-only * feat(S10): pixel do Meta na página pública de campanha (campo admin em SiteSettings + PageView na home + Lead no sucesso da captura) Campo tracking.facebookPixelId no global SiteSettings (mesma validação/normalização do precedente Petition, regra centralizada em facebookPixel.ts); home (grupo (home)) injeta o base code + PageView quando o ID está configurado (fail-closed) e o CampaignNewsletterForm dispara exatamente um Lead no sucesso do S9. Migration 20260819_213947_add_site_settings_facebook_pixel_id. Closes #94 * refactor(S10): simplify pass — Lead wiring unit-tested e raças de estado compartilhado do e2e resolvidas e2e: stub hermético de connect.facebook.net no guard compartilhado (specs paralelos renderizam a home com o pixel); advisory lock no Postgres serializando o login do usuário de teste (Payload perde sessão em login concorrente read-modify-write — classe documentada no webauthn); seedTestUser vira create-if-missing (sem janela de usuário ausente nem reset de senha); Lead do form protegido do catch de erro (tracking nunca falha uma captura commitada); descrição do campo compartilhada; unidade nova campaignNewsletterLead cobre exatamente um Lead no sucesso. * feat(C141): perfis de permissão por assessor — Visão × Edição configuráveis, enforcement fail-closed (Closes #105) * chore(C141): regenera changelog agregado após rebase com S10 * plans(C144): options write-scoped para formulários de escrita pós-C141 --------- Co-authored-by: Francisco Solla <admin@solla.dev>
|
|
@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**S10 — Pixel do Meta (Facebook) na página pública de campanha (2026-08-19):** o time de campanha paga pode configurar o ID numérico do Pixel (Dataset do Events Manager) no admin — global `SiteSettings` → group "Rastreamento / Ads" → "ID do Pixel do Meta (Facebook)", mesma validação/normalização do precedente dos abaixo-assinados (5–20 dígitos, sem HTML/JS; regras agora vivem num único dono em `facebookPixel.ts`, com o `Petition` refatorado para o helper compartilhado). Com o ID configurado, a home pública de campanha (`/`) carrega o base code + `PageView` do Meta (injeção no layout do grupo `(home)` via `next/script`, escopada só à página de campanha — nada de PageView vazando para notícias/etc.); sem ID, nada é carregado (fail-closed). A conclusão da captura de novidades (S9) dispara exatamente um `Lead` (`content_name` "novidades-da-campanha", `eventID` UUID) no padrão do `PetitionForm` — nunca no page load. Leitura via `getCachedGlobal('site-settings')` (ISR sob a tag `global_site-settings`; a edição do admin já revalida a home). Migração `20260819_213947_add_site_settings_facebook_pixel_id` (colunas `tracking_facebook_pixel_id` + versão, sem backfill). Fora da fatia (gatilhos herdados do plano da petição): CAPI/Advanced Matching, dedup `eventID`, fila/retry de `Lead` e cookie banner/CMP. E2e novo `campaignHomePixel` (script `#meta-pixel-*` presente só com ID configurado, CDN stubada; `mode: serial` por precedente).
|
||||
|
||||
**C141 — perfis de permissão por assessor: Visão × Edição configuráveis (2026-08-19):** o coordenador/candidato define por assessor, em `/campanha/assessores` (coluna "Permissão" com badge + popover, seção no detalhe e no draft de novo assessor), o que ele **vê** (`carteira|tudo`) e o que ele **edita** (`carteira|tudo|somente_leitura`) — contas novas e existentes seguem no padrão Carteira·Edita carteira até configurarem. Enforcement fail-closed no servidor: a leitura alarga com Visão "Tudo" via prólogo novo `resolveProfileScopedRead` (municípios, lideranças, updates, pledges, atividades, contatos derivados), a escrita segue o eixo de Edição (`advisorEditingAccess`) nos `canUpdate*`/`canCreate*` de todos os domínios, no check do giro e no create individual de atividade (ambos contra `getWritableMunicipalityIds`), e a ficha de contatos passou a ser autorizada pelo escopo de escrita (`assertPersonContactWritable` — antes era "você edita o que vê", que a Visão "Tudo" alargaria em escrita de PII). Caps mantidos: demandas seguem a regra própria (C143), apoiadores (PII) e telefones de contas ficam na carteira mesmo com Tudo, e escritas de coordenação (contas, `municipality.advisors`, nível de envolvimento E14, `allocationDecision`) continuam restritas. Combinação incoerente (Edição "Tudo" sem Visão "Tudo") é rejeitada no zod, na action e no hook `enforceCoherentAdvisorProfile` (REST/admin incluídos). Migration `20260819_214755_add_campaign_user_advisor_permission_profile` (duas colunas select com default `'carteira'`, sem backfill).
|
||||
|
||||
**OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. Durante o diagnóstico descobriu-se que a premissa do plano ("produção nunca recebeu o fix OPS69") estava errada: o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap **no histórico**, mas o commit S9 (`8d2352c0`, captura de novidades na home) tinha regenerado o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo o erro `getFromImportMap: PayloadComponent not found in importMap` — o admin abria em branco de novo ("funcionou e no deploy seguinte parou"). Verificado também que o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71): o GitHub tinha o fix + OPS71, mas não as features S8 (seção "Nossa história") e S9 (captura de novidades) que produção já rodava — deployar o main como estava regrediria as duas. A entrega: PR #751 re-homou no GitHub main as features S8+S9 (cherry-pick dos commits aprovados no Forgejo `8d2352c0` + `488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); CI verde; aguardando o merge. Deploy pós-merge via o pipeline canônico do OPS71 (dispatch manual do `deploy.yml`: `verify` full → job `deploy` no runner self-hosted recém-instalado no homeserver — 2 tentativas "stale run" por merges paralelos no meio do verify, terceira aplicou `dba75ff8`); migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: container na revision `dba75ff8`, healthy, **log sem nenhum `getFromImportMap`**, `/admin` renderizando o formulário de login (2 inputs email/senha + botão Login, título "Login - Payload", 0 erros de console), e a home pública mantendo "Nossa história" (S8) e o form de novidades (S9). Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições registradas: a regeneração do importMap sem envs `S3_*` re-órfão o handler (classe exata que o guard #87 deve impedir no CI); divergência de plataformas pós-cutover — workspace do homeserver ainda apontava para o Forgejo local (`localhost:3000`) e o S10 (pixel) segue pendente de re-home no GitHub.
|
||||
|
|
|
|||
1
docs/changelog/2026-08-19-s10.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**S10 — Pixel do Meta (Facebook) na página pública de campanha (2026-08-19):** o time de campanha paga pode configurar o ID numérico do Pixel (Dataset do Events Manager) no admin — global `SiteSettings` → group "Rastreamento / Ads" → "ID do Pixel do Meta (Facebook)", mesma validação/normalização do precedente dos abaixo-assinados (5–20 dígitos, sem HTML/JS; regras agora vivem num único dono em `facebookPixel.ts`, com o `Petition` refatorado para o helper compartilhado). Com o ID configurado, a home pública de campanha (`/`) carrega o base code + `PageView` do Meta (injeção no layout do grupo `(home)` via `next/script`, escopada só à página de campanha — nada de PageView vazando para notícias/etc.); sem ID, nada é carregado (fail-closed). A conclusão da captura de novidades (S9) dispara exatamente um `Lead` (`content_name` "novidades-da-campanha", `eventID` UUID) no padrão do `PetitionForm` — nunca no page load. Leitura via `getCachedGlobal('site-settings')` (ISR sob a tag `global_site-settings`; a edição do admin já revalida a home). Migração `20260819_213947_add_site_settings_facebook_pixel_id` (colunas `tracking_facebook_pixel_id` + versão, sem backfill). Fora da fatia (gatilhos herdados do plano da petição): CAPI/Advanced Matching, dedup `eventID`, fila/retry de `Lead` e cookie banner/CMP. E2e novo `campaignHomePixel` (script `#meta-pixel-*` presente só com ID configurado, CDN stubada; `mode: serial` por precedente).
|
||||
|
|
@ -91,9 +91,10 @@ history (`git revert` do PR da remoção, ou clone do main congelado do Forgejo)
|
|||
|
||||
## Rollback (do deploy)
|
||||
|
||||
A imagem anterior continua local e no registry (`localhost:5000/teqo-1313:<sha>`
|
||||
— o registry nunca deleta). O compose anterior fica em
|
||||
`~/stack/docker-compose.yml.pre-<sha-do-deploy-que-falhou>`.
|
||||
A imagem anterior **não** fica mais local após a limpeza pós-deploy (INF3/F2:
|
||||
`docker builder prune` + remoção das tags locais antigas, exceto o SHA em uso)
|
||||
— o registry `localhost:5000` **nunca deleta** e é a fonte do rollback. O
|
||||
compose anterior fica em `~/stack/docker-compose.yml.pre-<sha-do-deploy-que-falhou>`.
|
||||
|
||||
```bash
|
||||
ssh homeserver
|
||||
|
|
@ -102,7 +103,7 @@ cd ~/stack
|
|||
# o backup do deploy que falhou é o compose com os tags antigos
|
||||
ls docker-compose.yml.pre-* # escolha o anterior ao deploy ruim
|
||||
cp docker-compose.yml.pre-<sha-anterior> docker-compose.yml
|
||||
# 2. a imagem antiga ainda está local; se não, puxe do registry:
|
||||
# 2. puxar do registry (a imagem antiga já não fica local após a limpeza):
|
||||
docker pull localhost:5000/teqo-1313:<sha-anterior>
|
||||
docker tag localhost:5000/teqo-1313:<sha-anterior> teqo-1313:<sha-anterior>
|
||||
# 3. subir:
|
||||
|
|
|
|||
BIN
docs/plans/demandas-responsaveis-ui-draft-criacao.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/plans/demandas-responsaveis-ui-draft-detalhe.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
167
docs/plans/demandas-responsaveis-ui-draft.html
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<title>UI draft — Demandas: responsáveis explícitos</title>
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-100 antialiased">
|
||||
<main class="mx-auto max-w-6xl p-8">
|
||||
<p class="text-sm text-zinc-400">
|
||||
Demanda só aparece para candidato/coordenador e para quem está marcado como responsável —
|
||||
nem assessor do município vê fora disso. Cenas: detalhe com seção Responsáveis + editor,
|
||||
formulário de criação com sugestão.
|
||||
</p>
|
||||
|
||||
<!-- Cena 1: detalhe da demanda — visão de coordenador, com responsáveis -->
|
||||
<section data-shot="detalhe" class="mt-6 overflow-hidden rounded-xl border border-zinc-800">
|
||||
<div class="bg-zinc-100 px-8 py-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded bg-zinc-300 px-2 py-0.5 text-xs font-bold text-zinc-800"
|
||||
>Material de campanha</span
|
||||
>
|
||||
<span class="rounded bg-zinc-300 px-2 py-0.5 text-xs font-bold text-zinc-800"
|
||||
>Em aberto</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2 text-lg font-bold text-zinc-900">
|
||||
Cartazes para a caminhada do fim de semana
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">Ilhéus · liderança: Nome da liderança</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<div class="text-sm font-bold text-zinc-900">Descrição</div>
|
||||
<div class="mt-2 text-sm leading-relaxed text-zinc-600">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua.
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between border-t border-zinc-100 pt-3">
|
||||
<div class="text-xs text-zinc-500">Custo estimado</div>
|
||||
<div class="text-sm font-bold text-zinc-900">R$ 1.200,00</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<div class="text-xs text-zinc-500">Comprovantes</div>
|
||||
<div class="text-sm text-zinc-600">2 anexos</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between border-t border-zinc-100 pt-3">
|
||||
<div class="text-xs text-zinc-500">Visível para</div>
|
||||
<div class="text-sm text-zinc-600">Responsáveis · Candidato · Coordenador</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-bold text-zinc-900">Responsáveis pela demanda</div>
|
||||
<div class="text-xs text-zinc-400">Só quem é responsável vê esta demanda</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-1.5">
|
||||
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700"
|
||||
>Assessora de campo</span
|
||||
>
|
||||
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700"
|
||||
>Outro assessor</span
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-6 items-center rounded bg-zinc-100 px-2 text-xs text-zinc-500"
|
||||
>+2</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 h-9 w-full rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-500"
|
||||
>
|
||||
Adicionar responsável…
|
||||
</div>
|
||||
<div class="mt-2 text-[11px] text-zinc-500">
|
||||
Criador já entra como responsável automaticamente. Assessores do município aparecem
|
||||
como sugestão, mas só quem for marcado enxerga.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-md bg-zinc-100 px-4 py-2 text-xs leading-relaxed text-zinc-600">
|
||||
Assessor do município que não é responsável: a demanda não aparece na lista dele nem
|
||||
abre por URL — mesmo com permissão "Visão Tudo".
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cena 2: criação de demanda — campo responsáveis -->
|
||||
<section data-shot="criacao" class="mt-6 overflow-hidden rounded-xl border border-zinc-800">
|
||||
<div class="bg-zinc-100 px-8 py-6">
|
||||
<div class="text-base font-bold text-zinc-900">Nova demanda</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-xs font-bold text-zinc-700">Título</div>
|
||||
<div
|
||||
class="mt-1 h-9 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-500"
|
||||
>
|
||||
Cartazes para a caminhada do fim de semana
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold text-zinc-700">Município</div>
|
||||
<div
|
||||
class="mt-1 flex h-9 w-full items-center justify-between rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-800"
|
||||
>
|
||||
Ilhéus
|
||||
<span class="text-xs text-zinc-400">▾</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold text-zinc-700">Tipo</div>
|
||||
<div
|
||||
class="mt-1 flex h-9 w-full items-center justify-between rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-800"
|
||||
>
|
||||
Material de campanha
|
||||
<span class="text-xs text-zinc-400">▾</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-4">
|
||||
<div class="text-xs font-bold text-zinc-700">Responsáveis pela demanda</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span class="rounded bg-zinc-300 px-2 py-1 text-xs font-bold text-zinc-800"
|
||||
>Você (criador)</span
|
||||
>
|
||||
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700"
|
||||
>Assessora de campo</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 h-9 w-full rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-500"
|
||||
>
|
||||
Buscar assessor…
|
||||
</div>
|
||||
<div class="mt-2 text-[11px] text-zinc-500">
|
||||
Sugestões: assessores do município. Se ninguém for marcado além de você, só você,
|
||||
candidato e coordenador verão a demanda.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<div
|
||||
class="inline-flex h-9 items-center rounded-lg border border-zinc-300 px-4 text-sm font-bold text-zinc-700"
|
||||
>
|
||||
Cancelar
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex h-9 items-center rounded-lg bg-red-600 px-4 text-sm font-extrabold text-white"
|
||||
>
|
||||
Criar demanda
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
88
docs/plans/demandas-responsaveis.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Demandas visíveis apenas para responsáveis explícitos + candidato/coordenador
|
||||
|
||||
Status: aguardando execução
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #106
|
||||
Priority: P1
|
||||
Model: cursor-grok-4.5-high
|
||||
Impeccable: B — encaixe no detalhe de demanda (`/campanha/demandas/[slug]`) e na criação
|
||||
Rascunho UI: docs/plans/demandas-responsaveis-ui-draft.html
|
||||
Appetite: ~1–1,5 dias eng; campo de responsáveis + regra de visibilidade fail-closed
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
Demandas carregam custo estimado e comprovantes (controle interno de gastos). Hoje **qualquer assessor que administra o município da demanda a vê** — e a mesa descobriu que isso é amplo demais: a demanda deve ser vista **só por quem é responsável por ela**, pelo candidato e pelo coordenador geral. Mesmo um assessor do município relacionado, se não estiver marcado como responsável, não vê. Decidido no gate (2026-08-19, opção B): responsável é **vínculo explícito por demanda**.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** Alex (Coordenador Geral) e o Candidato acompanhando custos e decisões; assessores responsáveis acompanhando "suas" demandas; assessores do município que NÃO devem esbarrar em demandas que não são deles.
|
||||
- **Job principal:** quem cria a demanda (ou a coordenação) define os responsáveis; só eles — além de candidato/coordenador — enxergam e tocam a demanda.
|
||||
- **Fluxo desejado:**
|
||||
1. Um assessor cria uma demanda no município da carteira: o formulário pede "Responsáveis pela demanda" (criador já vem marcado; sugestões = assessores do município).
|
||||
2. No detalhe da demanda, quem pode atualizá-la adiciona/remove responsáveis.
|
||||
3. Um assessor do município que não é responsável: a demanda não aparece na lista dele, não abre por URL e não aparece em buscas — sem erro "sem permissão", simplesmente não existe para ele.
|
||||
- **Anti-goals de produto:** responsável herdado automaticamente do município (a decisão do gate foi explícita); visibilidade por município como default; auditoria de quem viu (fora de escopo).
|
||||
|
||||
### Esboço de fluxo (B/C/D)
|
||||
|
||||
```text
|
||||
[criar demanda] → [campo "Responsáveis" — criador pré-marcado, sugestão do município]
|
||||
→ [criar] → [criador vira responsável; lista só para responsáveis + candidato/coordenador]
|
||||
[detalhe] → [responsáveis gerenciam responsáveis]
|
||||
[assessor não-responsável] → [demanda invisível: lista, URL e busca]
|
||||
```
|
||||
|
||||
### Rascunho UI (B/C/D)
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- A demanda tem um conjunto de **responsáveis** (pessoas da campanha); candidato e coordenador sempre a veem, responsável ou não.
|
||||
- **Visibilidade fail-closed:** sem responsável marcado além do criador, a demanda é visível só ao criador + candidato/coordenador. Assessor do município relacionado, se não for responsável, **não vê** — nem na lista, nem por URL, nem na busca.
|
||||
- O **criador** entra automaticamente como responsável (garante pelo menos um dono e que a criação não suma do criador).
|
||||
- Quem pode **atualizar** a demanda pode gerenciar os responsáveis (mesma regra de visibilidade: responsáveis + candidato/coordenador).
|
||||
- A criação pede os responsáveis (sugestão = assessores do município da demanda), mas não obriga além do criador.
|
||||
- Guardrails: leader lockdown intacto (liderança nunca vê demandas); decisão de escalada continua candidato/coordenador; custo/comprovantes seguem a visibilidade da demanda.
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
- **Vou apresentar dados?** Não — regra de visibilidade e controle de acesso.
|
||||
- **Decisões desbloqueadas:** coordenação decide quem acompanha cada demanda.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** campo de responsáveis na demanda em `src/collections/CampaignDemand.ts`; regra de leitura/atualização em `src/utilities/access/demands.ts` (hoje `canReadCampaignDemand` = escopo do município — passa a ser "unrestricted ou responsável"); server actions `src/app/(campaign)/campanha/actions/demand.ts`; UI do detalhe `src/components/campaign/demand/` e do fluxo de criação (registrar pedido).
|
||||
- **Precedente a olhar:** o fragmento de escopo P3-D (não re-escrever o `{ municipality: { in: ids } }` por aí — a regra nova substitui o escopo municipal de demandas); `getFreshCampaignUser`/`memoizePerRequest` para a leitura por request; o padrão de "membership" de `municipality.advisors` para o controle de responsáveis.
|
||||
- **Risco de acoplamento:** atividades geram demandas (C90) e a lista/omnibox de demandas filtra por escopo — a regra nova muda o que assessores veem em todos os pontos; o Sollinha usa as mesmas queries de demanda (escopo por user).
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma dura. **Guardrail:** C141 (perfis por assessor) nunca contorna esta regra — Visão "Tudo" não abre demandas fora da responsabilidade.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Perfil de permissão por assessor (C141) e apresentação por perfil (C142).
|
||||
- Notificação/feed ao ser marcado responsável.
|
||||
- Auditoria de leituras da demanda.
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **"Responsável = assessor do município, simples."** Isso é exatamente o modelo de hoje, que o gate rejeitou. **Corte:** vínculo explícito; sugestões sim, herança automática não.
|
||||
- **"Vou usar o histórico de quem criou."** `createdBy` existe, mas só o criador não cobre quem vai executar a demanda. **Corte:** campo próprio de responsáveis; criador vira um responsável, não o único.
|
||||
- **"Vou esconder no cliente."** Esconder na UI não segura URL direta nem a API. **Corte:** regra no acesso de dados (fail-closed) — lista, URL e busca respeitam o mesmo where.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
Fechadas no gate. Assunções operacionais (sem pergunta nova): criador entra automaticamente como responsável; quem atualiza a demanda gerencia responsáveis; sugestões do campo = assessores do município da demanda; visível para = "Responsáveis · Candidato · Coordenador" (rótulo exibido no detalhe). _Assumidas — validar se contrariarem a mesa._
|
||||
|
||||
## Referências
|
||||
|
||||
- Decisão do gate 2026-08-19 (resposta 1): responsáveis explícitos; assessor do município não-responsável não vê.
|
||||
- Item irmão: `docs/plans/permissao-granular-assessores.md` (C141).
|
||||
- Rascunho UI (gate): `docs/plans/demandas-responsaveis-ui-draft.html` + PNGs acima.
|
||||
- Para abrir primeiro: `src/utilities/access/demands.ts`, `src/collections/CampaignDemand.ts`, `docs/plans/demandas-campanha.md`.
|
||||
- `AGENTS.md` — convenção P3-D do escopo de assessor.
|
||||
62
docs/plans/escopo-formularios-escrita-perfil.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Escala pós-C141: options write-scoped para formulários de escrita
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: C144 (a criar)
|
||||
Priority: P2
|
||||
Model: deepseek/deepseek-v4-flash
|
||||
Impeccable: A — backend de dados de formulário (o C142 cuida da apresentação)
|
||||
Appetite: ~0,5–1 dia eng
|
||||
|
||||
## Contexto (achado do /simplify da entrega C141)
|
||||
|
||||
`loadMunicipalityOptions` (`src/utilities/campaignRelationOptions.ts`) resolve os
|
||||
municípios oferecidos nos formulários de escrita com o escopo de LEITURA
|
||||
(`overrideAccess: false` + access de `canReadMunicipality`). Desde o C141, um
|
||||
assessor com **Visão "Tudo" + Edição "Carteira"** vê os 435 municípios nos
|
||||
pickers de atividade/demanda/apoiador/liderança, mas o servidor rejeita o
|
||||
submit fora da carteira (fail-closed, mensagem genérica) — e em **atividade**
|
||||
o servidor hoje **aceita** (o create individual foi corrigido no C141, mas o
|
||||
formulário continua oferecendo o estado inteiro). A apresentação por perfil é
|
||||
o item irmão C142; este item entrega a base de dados correta para os
|
||||
formulários de escrita.
|
||||
|
||||
## Objetivo
|
||||
|
||||
- Os pickers de município dos **formulários de escrita** oferecem apenas
|
||||
municípios no escopo de **escrita** do ator (`getWritableMunicipalityIds`):
|
||||
unrestricted/tudo → todos; carteira → carteira; somente leitura → nenhum
|
||||
(formulários de escrita nem abrem para esse assessor).
|
||||
- Superfícies alvo: atividade (create/update + giro), demanda (criar),
|
||||
apoiador (novo), liderança (criar/editar), organização (vínculos).
|
||||
- Leitura (listas/detalhes/mapa) continua read-scoped — não tocar.
|
||||
- Sem mudança de enforcement (C141 já fail-closed no servidor).
|
||||
|
||||
## Direção no codebase
|
||||
|
||||
- `src/utilities/campaignRelationOptions.ts` (`loadMunicipalityOptions`) —
|
||||
variante write-scoped (parametro `scope: 'read' | 'write'` ou helper irmão)
|
||||
consumida pelos form actions/pages de escrita; manter o fragmento P3-D
|
||||
(`advisorMunicipalityScopeWhere`) e o `getWritableMunicipalityIds` do C141.
|
||||
- Mapear os call sites dos pickers (atividades/agenda, demandas, apoiadores,
|
||||
lideranças, organizações) e trocar só os de escrita.
|
||||
|
||||
## Fases
|
||||
|
||||
1. Variante write-scoped + call sites de atividade (onde o servidor aceita) — tester int: picker de atividade reflete o eixo.
|
||||
2. Demanda/apoiador/liderança/organização (UX — servidor já rejeita).
|
||||
3. Gates (`pnpm push`); e2e no CI.
|
||||
|
||||
## Já resolvido no simplify/critique (não reabrir)
|
||||
|
||||
- Criação de atividade avulsa/giro fora do escopo: server-side no C141
|
||||
(`getWritableMunicipalityIds` no create individual e no batch do giro).
|
||||
- Ficha de contatos write-scoped (`assertPersonContactWritable`).
|
||||
- Ações de liderança write-scoped (`assertMunicipalitiesWithinScope`).
|
||||
- `allocationDecision` create capado na carteira.
|
||||
|
||||
## Explicitamente fora (deste lote)
|
||||
|
||||
- Apresentação por perfil (botões/controles/atalhos) — C142.
|
||||
- Enforcement server-side — C141 (já entregue).
|
||||
- Options de leitura (listas/detalhes) — read-scoped permanece.
|
||||
BIN
docs/plans/permissao-granular-assessores-ui-draft-desktop.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
docs/plans/permissao-granular-assessores-ui-draft-detalhe.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/plans/permissao-granular-assessores-ui-draft-mobile.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
322
docs/plans/permissao-granular-assessores-ui-draft.html
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<title>UI draft — Perfis de permissão por assessor</title>
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-100 antialiased">
|
||||
<main class="mx-auto max-w-6xl p-8">
|
||||
<p class="text-sm text-zinc-400">
|
||||
Coordenador/candidato define por assessor o que ele vê (Tudo | Carteira) e o que ele pode
|
||||
editar (Tudo | Carteira | Somente leitura) — cenas: lista com coluna Permissão + editor,
|
||||
mobile, detalhe do assessor.
|
||||
</p>
|
||||
|
||||
<!-- Cena 1: lista desktop com editor aberto numa linha -->
|
||||
<section data-shot="desktop" class="mt-6 overflow-hidden rounded-xl border border-zinc-800">
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-zinc-200 bg-zinc-100 px-6 py-4"
|
||||
>
|
||||
<div>
|
||||
<div class="h-5 w-44 rounded bg-zinc-500"></div>
|
||||
<div class="mt-1 h-2 w-64 rounded bg-zinc-400"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-9 w-44 rounded-full border border-zinc-300 bg-white px-3 text-sm text-zinc-500"
|
||||
>
|
||||
Buscar assessor…
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex h-9 items-center rounded-lg bg-red-600 px-4 text-sm font-extrabold text-white"
|
||||
>
|
||||
+ Novo assessor
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-100 px-6 pb-6">
|
||||
<!-- tabela -->
|
||||
<div class="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||
<div
|
||||
class="grid grid-cols-[1.3fr_1.7fr_1.1fr_1.7fr_1.5fr] gap-2 border-b border-zinc-200 bg-zinc-50 px-4 py-2 text-xs font-bold text-zinc-500"
|
||||
>
|
||||
<div>Nome</div>
|
||||
<div>E-mail</div>
|
||||
<div>Celular</div>
|
||||
<div>Municípios</div>
|
||||
<div>Permissão</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[1.3fr_1.7fr_1.1fr_1.7fr_1.5fr] items-center gap-2 border-b border-zinc-100 px-4 py-3 text-sm"
|
||||
>
|
||||
<div class="font-semibold text-zinc-900">Nome do assessor</div>
|
||||
<div class="text-zinc-600">assessor@exemplo.org</div>
|
||||
<div class="text-zinc-600">(71) 90000-0000</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700"
|
||||
>Salvador (19)</span
|
||||
>
|
||||
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700"
|
||||
>Feira de Santana</span
|
||||
>
|
||||
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700">+3</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="rounded bg-zinc-300 px-2 py-0.5 text-xs font-bold text-zinc-800"
|
||||
>Carteira · Edita carteira</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[1.3fr_1.7fr_1.1fr_1.7fr_1.5fr] items-center gap-2 border-b border-zinc-100 px-4 py-3 text-sm"
|
||||
>
|
||||
<div class="font-semibold text-zinc-900">Outra assessora</div>
|
||||
<div class="text-zinc-600">outra@exemplo.org</div>
|
||||
<div class="text-zinc-600">(71) 90000-0001</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700"
|
||||
>Camaçari</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="rounded bg-zinc-300 px-2 py-0.5 text-xs font-bold text-zinc-800"
|
||||
>Tudo · Somente leitura</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative grid grid-cols-[1.3fr_1.7fr_1.1fr_1.7fr_1.5fr] items-center gap-2 bg-zinc-50 px-4 py-3 text-sm pb-[28rem]"
|
||||
>
|
||||
<div class="font-semibold text-zinc-900">Assessora de campo</div>
|
||||
<div class="text-zinc-600">campo@exemplo.org</div>
|
||||
<div class="text-zinc-600">(71) 90000-0002</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700">Ilhéus</span>
|
||||
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700">Itabuna</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="rounded bg-zinc-300 px-2 py-0.5 text-xs font-bold text-zinc-800"
|
||||
>Carteira · Edita carteira</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- popover editor -->
|
||||
<div
|
||||
class="absolute left-4 top-6 z-10 w-80 rounded-lg border border-zinc-200 bg-white p-4 shadow-xl"
|
||||
>
|
||||
<div class="text-sm font-bold text-zinc-900">Permissão da conta</div>
|
||||
<div class="mt-1 text-xs text-zinc-500">Vale para todas as telas do /campanha.</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="text-xs font-bold text-zinc-700">Visão</div>
|
||||
<div
|
||||
class="mt-1 flex h-9 items-center justify-between rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-800"
|
||||
>
|
||||
Carteira — municípios que administra
|
||||
<span class="text-xs text-zinc-400">▾</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-zinc-500">
|
||||
Tudo: enxerga todos os municípios e lideranças.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="text-xs font-bold text-zinc-700">Edição</div>
|
||||
<div
|
||||
class="mt-1 flex h-9 items-center justify-between rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-800"
|
||||
>
|
||||
Carteira — edita só o que administra
|
||||
<span class="text-xs text-zinc-400">▾</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-zinc-500">
|
||||
Somente leitura: nenhum controle de edição em lugar nenhum.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-4 rounded-md bg-zinc-100 px-3 py-2 text-[11px] leading-relaxed text-zinc-600"
|
||||
>
|
||||
Demandas seguem regra própria: só quem é responsável pela demanda (e
|
||||
candidato/coordenador) a vê — mesmo com visão "Tudo".
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<div
|
||||
class="inline-flex h-9 items-center rounded-lg border border-zinc-300 px-4 text-sm font-bold text-zinc-700"
|
||||
>
|
||||
Cancelar
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex h-9 items-center rounded-lg bg-red-600 px-4 text-sm font-extrabold text-white"
|
||||
>
|
||||
Salvar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-xs text-zinc-500">
|
||||
Padrão de contas novas: Carteira · Edita carteira (comportamento atual). Alterações
|
||||
valem na hora — ninguém precisa reenviar senha.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cena 2: mobile — lista em cards -->
|
||||
<section
|
||||
data-shot="mobile"
|
||||
class="mt-6 w-[390px] overflow-hidden rounded-xl border border-zinc-800"
|
||||
>
|
||||
<div class="bg-zinc-100 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="h-5 w-36 rounded bg-zinc-500"></div>
|
||||
<div
|
||||
class="inline-flex h-9 items-center rounded-lg bg-red-600 px-4 text-sm font-extrabold text-white"
|
||||
>
|
||||
+ Novo
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 h-9 w-full rounded-full border border-zinc-300 bg-white px-3 text-sm text-zinc-500"
|
||||
>
|
||||
Buscar assessor…
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-lg border border-zinc-200 bg-white p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 truncate font-semibold text-zinc-900">Nome do assessor</div>
|
||||
<span
|
||||
class="shrink-0 rounded bg-zinc-300 px-2 py-0.5 text-[11px] font-bold text-zinc-800"
|
||||
>Carteira · Edita</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-zinc-600">assessor@exemplo.org · (71) 90000-0000</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-[11px] text-zinc-700"
|
||||
>Salvador (19)</span
|
||||
>
|
||||
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-[11px] text-zinc-700"
|
||||
>Feira de Santana</span
|
||||
>
|
||||
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-[11px] text-zinc-700">+3</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 rounded-lg border border-zinc-200 bg-white p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 truncate font-semibold text-zinc-900">Outra assessora</div>
|
||||
<span
|
||||
class="shrink-0 rounded bg-zinc-300 px-2 py-0.5 text-[11px] font-bold text-zinc-800"
|
||||
>Tudo · Só leitura</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-zinc-600">outra@exemplo.org · (71) 90000-0001</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-[11px] text-zinc-700"
|
||||
>Camaçari</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 rounded-lg border border-zinc-200 bg-white p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 truncate font-semibold text-zinc-900">Assessora de campo</div>
|
||||
<span
|
||||
class="shrink-0 rounded bg-zinc-300 px-2 py-0.5 text-[11px] font-bold text-zinc-800"
|
||||
>Carteira · Edita</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-zinc-600">campo@exemplo.org · (71) 90000-0002</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-[11px] text-zinc-700"
|
||||
>Ilhéus</span
|
||||
>
|
||||
<span class="rounded bg-zinc-200 px-1.5 py-0.5 text-[11px] text-zinc-700"
|
||||
>Itabuna</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cena 3: detalhe do assessor com seção de permissão -->
|
||||
<section data-shot="detalhe" class="mt-6 overflow-hidden rounded-xl border border-zinc-800">
|
||||
<div class="bg-zinc-100 px-8 py-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-full bg-zinc-400"></div>
|
||||
<div>
|
||||
<div class="text-lg font-bold text-zinc-900">Nome do assessor</div>
|
||||
<div class="text-sm text-zinc-500">assessor@exemplo.org · (71) 90000-0000</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm font-bold text-zinc-700"
|
||||
>
|
||||
Enviar link de redefinição de senha
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<div class="text-sm font-bold text-zinc-900">Permissão da conta</div>
|
||||
<div class="mt-3">
|
||||
<div class="text-xs font-bold text-zinc-700">Visão</div>
|
||||
<div
|
||||
class="mt-1 flex h-9 items-center justify-between rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-800"
|
||||
>
|
||||
Carteira — municípios que administra
|
||||
<span class="text-xs text-zinc-400">▾</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="text-xs font-bold text-zinc-700">Edição</div>
|
||||
<div
|
||||
class="mt-1 flex h-9 items-center justify-between rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-800"
|
||||
>
|
||||
Carteira — edita só o que administra
|
||||
<span class="text-xs text-zinc-400">▾</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 rounded-md bg-zinc-100 px-3 py-2 text-[11px] leading-relaxed text-zinc-600"
|
||||
>
|
||||
Demandas ficam sempre limitadas aos municípios de responsabilidade. Decisões de
|
||||
coordenação (nível de envolvimento, contas, demandas escaladas) continuam restritas
|
||||
a coordenador e candidato.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<div class="text-sm font-bold text-zinc-900">Municípios administrados</div>
|
||||
<div class="mt-3 flex flex-wrap gap-1.5">
|
||||
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700"
|
||||
>Salvador (19)</span
|
||||
>
|
||||
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700"
|
||||
>Feira de Santana</span
|
||||
>
|
||||
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700">Camaçari</span>
|
||||
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700">Ilhéus</span>
|
||||
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700">Itabuna</span>
|
||||
<span
|
||||
class="inline-flex h-6 items-center rounded bg-zinc-100 px-2 text-xs text-zinc-500"
|
||||
>+8</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 h-9 w-full rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-500"
|
||||
>
|
||||
Adicionar município…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
101
docs/plans/permissao-granular-assessores.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Perfis de permissão por assessor (visão × edição), configuráveis pelo coordenador/candidato
|
||||
|
||||
Status: aguardando execução
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #105
|
||||
Priority: P1
|
||||
Model: cursor-grok-4.5-high
|
||||
Impeccable: B — encaixe em `/campanha/assessores` (lista + detalhe + novo)
|
||||
Rascunho UI: docs/plans/permissao-granular-assessores-ui-draft.html
|
||||
Appetite: ~2–3 dias eng; um modelo de permissão + configuração + enforcement fail-closed
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
Hoje todo assessor tem exatamente um perfil: **vê e edita só os municípios da carteira**. No campo, a equipe descobriu que isso não cobre a mesa real: há assessor que precisa **ver tudo** (panorama, relatório, cobertura) sem poder **alterar nada**; há assessor que **vê tudo** mas só pode mexer **naquilo pelo que é responsável**; há assessor que só vê o que é seu — com ou sem edição. E **demandas** carregam custo e comprovantes (controle interno): só candidato, coordenador e as **pessoas responsáveis pela demanda** as veem — regra própria, detalhada no item irmão (C143).
|
||||
|
||||
O coordenador/candidato precisa configurar isso **por conta**, na vertical `/campanha`, sem depender do Payload admin.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** Alex (Coordenador Geral) e o Candidato na mesa — montar e ajustar o time de assessores conforme o papel real de cada um; e o assessor, operando o `/campanha` dentro do que lhe foi liberado.
|
||||
- **Job principal:** para cada assessor, definir **o que ele vê** e **o que ele pode editar**, em dois controles simples; e ter isso aplicado na hora, em todas as telas.
|
||||
- **Fluxo desejado:**
|
||||
1. Alex abre `/campanha/assessores` e vê uma coluna "Permissão" por assessor (ex.: "Carteira · Edita carteira").
|
||||
2. Toca no perfil de um assessor e escolhe **Visão** (Tudo | Carteira) e **Edição** (Tudo | Carteira | Somente leitura); a tela explica cada combinação e lembra que demandas seguem regra própria.
|
||||
3. Salva. O assessor entra e opera exatamente dentro do perfil — se "Somente leitura", não encontra nenhum controle de edição; se "Tudo", navega o estado inteiro (demandas fora, que seguem a regra de responsáveis).
|
||||
4. Conta nova nasce no padrão atual (Carteira · Carteira) — nada muda até alguém mexer.
|
||||
- **Anti-goals de produto:** papel novo (só o assessor ganha perfis; liderança continua em lockdown e candidato/coordenador intocados); IAM tipo SaaS (grupos, herança, policy engine); permissão fina por coleção ("vê lideranças mas não atividades"); autoatendimento do próprio perfil.
|
||||
|
||||
### Esboço de fluxo (B/C/D)
|
||||
|
||||
```text
|
||||
[assessores lista] → [coluna Permissão] → [editor: Visão × Edição + avisos]
|
||||
→ [salvar] → [perfil ativo imediatamente]
|
||||
[assessor entra] → [telas renderizam e respondem dentro do perfil — fail-closed no servidor]
|
||||
```
|
||||
|
||||
### Rascunho UI (B/C/D)
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- O coordenador/candidato define, por assessor: **Visão** (Tudo | Carteira) e **Edição** (Tudo | Carteira | Somente leitura), nas superfícies de `/campanha/assessores` (lista, detalhe, novo).
|
||||
- Contas novas e existentes mantêm o perfil atual por default — zero mudança de comportamento até configurarem.
|
||||
- O assessor vê/edita **exatamente** o que o perfil permite, em todas as telas do `/campanha` e nas APIs — a checagem falha-fechada no servidor (nunca só ocultar botão no cliente).
|
||||
- **Demandas:** ficam fora dos eixos — mesmo com Visão "Tudo", o assessor só vê demandas de que é responsável (regra explícita do item C143); Visão "Tudo" não abre demandas nem custo/comprovantes.
|
||||
- Edição "Tudo" **não** libera escritas de coordenação: gerenciar contas, atribuir assessores a municípios, mover nível de envolvimento e decidir demandas escaladas seguem restritos a candidato/coordenador.
|
||||
- Combinação incoerente (ex.: editar o que não vê) não é oferecida.
|
||||
- **Apoiadores** (PII) continuam capados na carteira para todo staff abaixo de candidato/coordenador, mesmo com Visão "Tudo" _(decidido no gate)_.
|
||||
- Configuração em três pontos: coluna + editor inline na lista, seção no detalhe e campos no formulário de novo assessor _(decidido no gate)_. Edit-where-you-see.
|
||||
- Guardrails do repo intactos: liderança em lockdown, assimetria de votos (estimativas staff-only), postura LGPD fail-closed.
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
- **Vou apresentar dados?** Não — superfície de configuração de contas; sem KPI eleitoral.
|
||||
- **Decisões desbloqueadas:** coordenador decide quem vê o quê e quem edita o quê, por pessoa.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** perfis na conta em `src/collections/CampaignUser.ts`; enforcement nos módulos de acesso `src/utilities/access/*` (o prologue `resolveActorScopedRead`/`resolveAccessibleIds` em `shared.ts` e os módulos por domínio — municípios, lideranças, pledges, demandas, atividades, organizações, dobradinhas, apoiadores); gates de escrita nas server actions `src/app/(campaign)/campanha/actions/*.ts`; UI em `src/components/campaign/advisor/` (`AdvisorsTable`, detalhe `[id]`, formulário novo) e helpers de papel client-safe em `src/lib/campaignRoles.ts`.
|
||||
- **Precedente a olhar:** B19 (`gerenciar-assessores.md`) — a superfície e os gates de conta; a convenção P3-D do escopo de assessor (uma só grafia do fragmento `{ municipality(s): { in: ids } }`); C116/C116+ — células edit-in-place com estados read-only por papel.
|
||||
- **Risco de acoplamento:** o escopo do assessor é central e compartilhado (mapa, quantis por escopo, listas, dossiê, Sollinha); qualquer mudança no "o que o assessor vê" propaga. Respeitar leader lockdown e o "não re-escrever o fragmento de escopo" da convenção.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma dura. **Guardrail:** a regra de demandas (C143) não depende deste item, e Visão "Tudo" nunca a contorna. C142 (superfícies respeitando o perfil) depende deste.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Perfis para `coordinator`/`candidate`/`leader` (congelados por política de produto).
|
||||
- Demanda com "responsáveis" explícitos por demanda (vínculo por pessoa) — ver Questões; se confirmado, item sucessor.
|
||||
- Permissão fina por coleção ou por campo além da regra de demandas.
|
||||
- Autoatendimento/edição do próprio perfil.
|
||||
- Histórico/auditoria de mudanças de perfil.
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **"Já que tem visão, vou adicionar um role novo."** Papel novo (`viewer`, `auditor`) recria o modelo de papéis e o seed de contas. **Corte:** perfis sobre o papel `advisor` existente — um assessor "visão Tudo · Somente leitura" É o leitor.
|
||||
- **"Vou pedir permissão por coleção."** Cada campo novo vira matriz N×M e a tela vira IAM. **Corte:** dois eixos só; o que não couber nos eixos fica com a regra das demandas ou fora.
|
||||
- **"Vou esconder botão no cliente e pronto."** Acesso de verdade mora no servidor; esconder botão é cosmético. **Corte:** enforcement no acesso de dados e nas actions primeiro; a apresentação é a entrega irmã (item seguinte).
|
||||
- **"Visão Tudo abre as planilhas PII."** Apoiadores/custo de demandas são sensíveis (LGPD/controle interno). **Corte:** apoiadores capados na carteira (decidido); demandas seguem a regra de responsáveis (C143).
|
||||
|
||||
## Decisões do gate (2026-08-19)
|
||||
|
||||
- **Responsável por uma demanda = vínculo explícito por demanda** (opção B da rodada) — mesmo o assessor do município da demanda, se não estiver marcado como responsável, não vê. A regra vive no item irmão **C143**; este item apenas garante que Visão "Tudo" não a contorna.
|
||||
- **Visão "Tudo" não abre superfícies sensíveis** (opção A) — apoiadores (PII) seguem capados na carteira; demandas seguem a regra de responsáveis (C143).
|
||||
- **Edição "Tudo" não libera escritas de coordenação** (opção A).
|
||||
- **Configuração na lista + detalhe + formulário de novo assessor** (opção A).
|
||||
|
||||
## Referências
|
||||
|
||||
- Pedido de produto 2026-08-19 (vertical `/campanha` — granularidade de acesso por conta); decisões do gate na seção acima.
|
||||
- Item irmão: `docs/plans/demandas-responsaveis.md` (C143 — regra de responsáveis de demanda).
|
||||
- Rascunho UI (gate): `docs/plans/permissao-granular-assessores-ui-draft.html` + PNGs acima.
|
||||
- Para abrir primeiro: `src/utilities/access/shared.ts` (`resolveActorScopedRead`), `src/utilities/access/demands.ts` (regra atual de demandas = escopo de leitura), `src/components/campaign/advisor/AdvisorsTable.tsx`, `docs/plans/gerenciar-assessores.md` (B19).
|
||||
- `AGENTS.md` — modelo de papéis de campanha, convenção P3-D do escopo de assessor.
|
||||
87
docs/plans/pixel-meta-pagina-campanha-impl.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# Impl: Pixel do Meta (Facebook) na página pública de campanha
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #94
|
||||
Intenção: docs/plans/pixel-meta-pagina-campanha.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** o time de campanha paga configura no admin um campo opcional "ID do Pixel do Meta" nas configurações do site; a home pública de campanha (`/`) passa a carregar o base code + `PageView` do Meta quando o ID está presente e válido; a conclusão da captura de novidades (S9) dispara exatamente um `Lead`. Sem ID configurado, nada é carregado (fail-closed, zero impacto).
|
||||
- **O que NÃO negociar:** persistir **só o ID numérico** (5–20 dígitos, sem HTML/JS colado — XSS); `Lead` **somente no sucesso** da captura, nunca no page load; Pixel **só na página pública de campanha** (não vazar PageViews de notícias/etc.); sem env var obrigatória; sem CAPI/Advanced Matching, cookie banner/CMP e dedup `eventID` nesta fatia (herda gatilhos do plano da petição); cache da home revalidado pela edição do admin.
|
||||
- **O que reavaliar:** a hipótese da intenção dizia "home `src/app/(frontend)/(home)/page.tsx` (injeção do `<MetaPixel />`)" — a injeção pode morar no **layout** do grupo `(home)` (que cobre exatamente a única rota do grupo) em vez da página; e "form de captura do S9" é `CampaignNewsletterForm` (cliente) — a seam do `Lead` é o callback `onSubmit()` já previsto no impl plan do S9. A leitura do global na home é compatível com o caching: `getCachedGlobal('site-settings')` é `unstable_cache` sob a tag `global_site-settings`, já revalidada pelo `afterChange` existente (a home passa a ser ISR nessa tag — mesmo padrão de `SiteHeader`/`socialLinks`).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Admin["SiteSettings global<br/>tracking.facebookPixelId (group Rastreamento/Ads)"]
|
||||
Home["(home)/layout.tsx<br/>getCampaignHomeMetaPixelId"]
|
||||
Pixel["MetaPixel client<br/>next/script + fbq init/PageView"]
|
||||
Section["CampaignNewsletterSection<br/>getCampaignHomeMetaPixelId"]
|
||||
Form["CampaignNewsletterForm<br/>pixelId prop"]
|
||||
Action["submitCampaignNewsletter"]
|
||||
Lead["trackMetaLead<br/>content_name novidades-da-campanha"]
|
||||
Meta["Meta Events Manager"]
|
||||
|
||||
Admin --> Home
|
||||
Home --> Pixel
|
||||
Pixel --> Meta
|
||||
Section --> Form
|
||||
Form --> Action
|
||||
Action -->|sucesso--> Lead
|
||||
Lead --> Meta
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (campo no global `SiteSettings` + injeção no layout do grupo `(home)`) · B (novo global de rastreamento dedicado) · C (injeção no `page.tsx` em vez do layout) · D (validate compartilhado em `facebookPixel.ts` vs. duplicado inline)
|
||||
**Recomendação:** **A+D** — porque o dono existe com revalidação pronta (`afterChange` → `revalidateGlobal('site-settings')`, apontado pelo plano da petição como destino natural de um Pixel global); o layout do grupo `(home)` cobre **exatamente** a página pública de campanha (única rota do grupo), escopando o Pixel ao aceite e impedindo vazamento; e as regras de ID do Pixel passam a viver num único módulo profundo (`facebookPixel.ts` já é o dono), com o validate do `Petition` migrando para o helper compartilhado (edit owner, sem gêmeo).
|
||||
**Rejeitadas:**
|
||||
|
||||
- **B (global de rastreamento dedicado):** twin do `SiteSettings` — o dono já existe, o plano da petição já o apontava, e um segundo global seria cerimônia sem volatilidade.
|
||||
- **C (injeção no `page.tsx`):** funcionaria (única rota do grupo), mas o layout é o lugar que declara a superfície "página pública de campanha" (`data-theme="campaign-site"`) — se o grupo ganhar outra rota pública de campanha, o Pixel a cobre por contrato; e `CampaignNewsletterSection` continuaria precisando do pixelId de qualquer forma (2 call sites da mesma leitura).
|
||||
- **D-duplicado (validate inline copiado do `Petition`):** a repetição do bloco validação/label/description é exatamente o tipo de drift que o precedente da petição adiou com gatilho ("revisitar quando o mesmo ID for colado em ≥3 petições") — o gatilho foi puxado por produto; agora as regras devem ter dono único.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/facebookPixel.ts`** (edit owner): adicionar `validateFacebookPixelId(value: string | null | undefined): true | string` — envolve `normalizeFacebookPixelId` devolvendo a mensagem pt-BR do admin ("Informe somente o ID numérico do Pixel (5 a 20 dígitos), sem HTML ou script.") quando inválido; `true` quando vazio ou válido (campo opcional). Módulo já importado por client — helper puro, sem `server-only`.
|
||||
- **`src/collections/Petition.ts`** (edit owner): o `validate` inline do campo `tracking.facebookPixelId` passa a chamar `validateFacebookPixelId` (remove a duplicação; comportamento idêntico). `beforeChange` e campo inalterados.
|
||||
- **`src/globals/SiteSettings.ts`** (edit owner): novo group `tracking` (label "Rastreamento / Ads", mesmo do `Petition`) com o campo `facebookPixelId` — `type: 'text'`, opcional, label "ID do Pixel do Meta (Facebook)", `admin.description` idêntica ao precedente, `validate: validateFacebookPixelId`; novo hook `beforeChange` normalizando `data.tracking.facebookPixelId` (mesmo padrão do `Petition`). `afterChange`/access inalterados (`update: payloadAdminOnly` já cobre).
|
||||
- **`src/utilities/campaignHomeTracking.ts`** (novo, `server-only`): `getCampaignHomeMetaPixelId(): Promise<string | null>` — `getCachedGlobal('site-settings')()` → `normalizeFacebookPixelId(settings.tracking?.facebookPixelId)`. Esconde a forma do global + normalização; 2 call sites (layout + section); não é pass-through — é a fronteira do tracking da home.
|
||||
- **`src/app/(frontend)/(home)/layout.tsx`** (edit owner): `const pixelId = await getCampaignHomeMetaPixelId()`; `{pixelId ? <MetaPixel pixelId={pixelId} /> : null}` dentro do container do grupo. Leitura cacheada sob `global_site-settings` — a edição do admin revalida a home (ISR), guardrail da intenção.
|
||||
- **`src/app/(frontend)/(home)/CampaignNewsletterSection.tsx`** (edit owner): lê `getCampaignHomeMetaPixelId()` e passa `pixelId={pixelId ?? undefined}` ao `CampaignNewsletterCapture`.
|
||||
- **`src/components/CampaignNewsletterForm.tsx`** (edit owner): `CampaignNewsletterForm` ganha prop opcional `pixelId?: string`; no sucesso de `submitCampaignNewsletter` (após `await`, antes do swap `onSubmit()`), `if (pixelId) trackMetaLead(pixelId, NEWSLETTER_LEAD_CONTENT_NAME, crypto.randomUUID())` — padrão exato do `PetitionForm` (linha 61-63). Constante de módulo `NEWSLETTER_LEAD_CONTENT_NAME = 'novidades-da-campanha'` (valor estável visível no Events Manager, mesmo padrão de `content_name` da petição). `CampaignNewsletterCapture` repassa a prop.
|
||||
- **Migration:** `pnpm migrate:create add_site_settings_facebook_pixel_id` — colunas `tracking_facebook_pixel_id` (tabela `site_settings`) + `version_tracking_facebook_pixel_id` (tabela `site_settings_versions`); sem backfill, sem dado novo. Inspcionar SQL gerado antes de aplicar.
|
||||
- **Access / Consent:** nenhum — campo herdado do access do global (`update: payloadAdminOnly`), sem PII nova, sem chave de Consent (mesmo precedente da petição: transparência de cookies fica no `/privacidade`, dependência suave herdada).
|
||||
- **UI:** Impeccable A — N/A (campo único no admin replicando o precedente aceito + script invisível ao visitante; zero superfície pública nova).
|
||||
|
||||
### Dados → forma (não se aplica)
|
||||
|
||||
O dado (PageView/Lead) vive no Meta Events Manager; o app só emite o evento. Nenhuma forma de apresentação nova.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / schema+server** — migration + `SiteSettings` (group/campo/beforeChange) + `validateFacebookPixelId` em `facebookPixel.ts` + refactor `Petition`; `pnpm migrate` local (teqo e teqo_test) + `pnpm generate:types`; unit do `validateFacebookPixelId` em `tests/unit/facebookPixel.unit.spec.ts`; `pnpm exec tsc --noEmit`.
|
||||
2. **UI/leitura** — `campaignHomeTracking.ts` + layout com `<MetaPixel>` + section/form com a seam do `Lead`; e2e novo `tests/e2e/campaignHomePixel.e2e.spec.ts` (configura o global via REST admin com ID válido fixo, carrega a home com `?e2e=`, assere o script `#meta-pixel-<id>`, restaura o global para null no cleanup — idempotente, chave própria, paralelo-safe no DB de teste); spec do S9 inalterado (sem pixel configurado, nada renderiza).
|
||||
3. **Gates** — `pnpm gate:fast`; `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build` (com `S3_*` dummy e `generate:importmap` por via das dúvidas); changelog `docs/changelog/2026-08-19-s10.md` + `pnpm changelog:build`; push e PR via `pnpm push` (base `main` no Forgejo, `Closes #94`).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não mexer em `submitCampaignNewsletter` (action intacta — o `Lead` é puramente client-side, mesmo padrão da petição).
|
||||
- Não adicionar fila/retry de `Lead` quando `window.fbq` não carregou (race submit rápido vs. `afterInteractive`) — **herda o gatilho** do plano da petição (revisitar quando smoke com Meta Pixel Helper mostrar `Lead` ausente em produção).
|
||||
- Não usar o ID do Pixel em outras páginas/superfícies do site.
|
||||
- Não tocar CSP, env vars, `@types` de terceiros para `fbq` (tipagem local já existe).
|
||||
- Não indexar/unicidade do campo (mesmo ID pode aparecer em petição e home — aceite da intenção).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Home ISR sob `global_site-settings`:** a primeira render pós-save usa o valor novo (tag busta a cache); o e2e usa o truque do query param (`?e2e=`) para bypass de fetch cache — precedente do spec S9. Nenhuma página fora do grupo `(home)` lê o pixel.
|
||||
- **Pins de geometria do e2e `frontend`:** `MetaPixel` só renderiza com ID configurado; o spec existente roda sem pixel → DOM inalterado (script + `noscript` `display:none` não deslocam layout). Sem re-pin esperado.
|
||||
- **Global compartilhado no e2e:** o cleanup restaura `tracking.facebookPixelId` para `null` (update via REST); idempotente por run, DB de teste dedicado por worktree.
|
||||
- **Concorrência de migration:** rebase em `forgejo/main` já feito; criar a migration após; cadeia validada por `pnpm migrate` + `test:int` no PR.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (campo admin com mesma validação; PageView na home com fail-closed; exatamente um `Lead` no sucesso; cache revalidado)
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem HTML colado; edit owner sem gêmeos; copy pt-BR/identifiers EN; `unstable_cache`/tag existentes)
|
||||
- [ ] Testes de domínio previstos: unit `validateFacebookPixelId` (novo helper) + e2e de injeção do base code condicional
|
||||
|
|
@ -110,6 +110,19 @@ client'`, precedente `CampaignCarousel`) — copy (eyebrow/título/copy do rascu
|
|||
3. **Gates** — `pnpm gate:fast`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`,
|
||||
`pnpm test`, `pnpm build`; e2e no CI; push via `pnpm push`.
|
||||
|
||||
## Conflitos de rebase resolvidos (2026-08-19)
|
||||
|
||||
- **S9 mergeou em main durante a execução** e inseriu `CampaignNewsletterSection` no mesmo ponto
|
||||
(após bandeiras, antes do footer). Resolução: **história → newsletter → footer** — o funil
|
||||
põe o "ask" (formulário de captura) depois do fechamento emocional; S9 declara "logo acima do
|
||||
rodapé" e S8 "após bandeiras"; ambas as intenções ficam satisfeitas.
|
||||
- **Changelog agregado**: conflito insert-only resolvido com `git checkout --theirs` +
|
||||
`pnpm changelog:build` (S8 re-anexado sobre o agregado com S7/S9).
|
||||
- Nota de ambiente: o e2e local (dev server) acusa warnings de React keys em
|
||||
`CampaignColumnPicker`/omnibox de assessores (B137/B197, era C139) que o guard falha — são
|
||||
**dev-only** (build de produção do CI não emite warnings de keys), pré-existentes em main e
|
||||
fora do escopo S8.
|
||||
|
||||
## Débitos (capture-review-debts — triage autônoma)
|
||||
|
||||
- **Absorvidos nesta entrega:** JSDoc enxuto; e2e sem `toHaveURL` redundante; asserts de
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 39 KiB |
230
docs/plans/ui-respeita-perfil-permissao-ui-draft.html
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<title>UI draft — Interface respeita o perfil de permissão</title>
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-100 antialiased">
|
||||
<main class="mx-auto max-w-6xl p-8">
|
||||
<p class="text-sm text-zinc-400">
|
||||
Depois do perfil de permissão (C141): as telas param de oferecer edição a quem não pode
|
||||
editar e passam a renderizar o escopo completo para quem vê tudo — cenas: lista de
|
||||
municípios em modo leitura, lista com visão total, detalhe de demanda somente leitura.
|
||||
</p>
|
||||
|
||||
<!-- Cena 1: lista de municípios — assessor somente leitura -->
|
||||
<section
|
||||
data-shot="municipios-leitura"
|
||||
class="mt-6 overflow-hidden rounded-xl border border-zinc-800"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-zinc-200 bg-zinc-100 px-6 py-4"
|
||||
>
|
||||
<div>
|
||||
<div class="h-5 w-44 rounded bg-zinc-500"></div>
|
||||
<div class="mt-1 h-2 w-64 rounded bg-zinc-400"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-100 px-6 pb-6">
|
||||
<div class="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||
<div
|
||||
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] gap-2 border-b border-zinc-200 bg-zinc-50 px-4 py-2 text-xs font-bold text-zinc-500"
|
||||
>
|
||||
<div>Município</div>
|
||||
<div>Tendência</div>
|
||||
<div>Nível</div>
|
||||
<div>Classe</div>
|
||||
<div>Cobertura</div>
|
||||
<div>Últ. atualização</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] items-center gap-2 border-b border-zinc-100 px-4 py-3 text-sm"
|
||||
>
|
||||
<div class="min-w-0 truncate font-semibold text-zinc-900">Salvador (19 zonas)</div>
|
||||
<div class="min-w-0 text-zinc-600">Favorável</div>
|
||||
<div class="min-w-0 text-zinc-600">N3</div>
|
||||
<div class="min-w-0 text-zinc-600">Reduto</div>
|
||||
<div class="min-w-0 text-zinc-600">82%</div>
|
||||
<div class="min-w-0 text-zinc-600">há 2 dias</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] items-center gap-2 border-b border-zinc-100 px-4 py-3 text-sm"
|
||||
>
|
||||
<div class="min-w-0 truncate font-semibold text-zinc-900">Feira de Santana</div>
|
||||
<div class="min-w-0 text-zinc-600">Neutro</div>
|
||||
<div class="min-w-0 text-zinc-600">N2</div>
|
||||
<div class="min-w-0 text-zinc-600">Expansão</div>
|
||||
<div class="min-w-0 text-zinc-600">41%</div>
|
||||
<div class="min-w-0 text-zinc-600">há 5 dias</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] items-center gap-2 px-4 py-3 text-sm"
|
||||
>
|
||||
<div class="min-w-0 truncate font-semibold text-zinc-900">Ilhéus</div>
|
||||
<div class="min-w-0 text-zinc-600">Favorável</div>
|
||||
<div class="min-w-0 whitespace-nowrap text-zinc-600">—</div>
|
||||
<div class="min-w-0 text-zinc-600">Manutenção</div>
|
||||
<div class="min-w-0 text-zinc-600">57%</div>
|
||||
<div class="min-w-0 text-zinc-600">há 1 dia</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-xs text-zinc-500">
|
||||
Mesma lista de sempre — mas sem autosave, sem chips editáveis, sem "Registrar
|
||||
atualização": nenhum caminho de escrita visível.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cena 2: mesma lista para assessor com visão Tudo -->
|
||||
<section
|
||||
data-shot="municipios-visao-tudo"
|
||||
class="mt-6 overflow-hidden rounded-xl border border-zinc-800"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-zinc-200 bg-zinc-100 px-6 py-4"
|
||||
>
|
||||
<div>
|
||||
<div class="h-5 w-44 rounded bg-zinc-500"></div>
|
||||
<div class="mt-1 h-2 w-64 rounded bg-zinc-400"></div>
|
||||
</div>
|
||||
<div
|
||||
class="h-9 w-44 rounded-full border border-zinc-300 bg-white px-3 text-sm text-zinc-500"
|
||||
>
|
||||
Buscar município…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-100 px-6 pb-6">
|
||||
<div class="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||
<div
|
||||
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] gap-2 border-b border-zinc-200 bg-zinc-50 px-4 py-2 text-xs font-bold text-zinc-500"
|
||||
>
|
||||
<div>Município</div>
|
||||
<div>Tendência</div>
|
||||
<div>Nível</div>
|
||||
<div>Classe</div>
|
||||
<div>Cobertura</div>
|
||||
<div>Últ. atualização</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] items-center gap-2 border-b border-zinc-100 px-4 py-3 text-sm"
|
||||
>
|
||||
<div class="min-w-0 truncate font-semibold text-zinc-900">Salvador (19 zonas)</div>
|
||||
<div class="min-w-0 text-zinc-600">Favorável</div>
|
||||
<div class="min-w-0 text-zinc-600">N3</div>
|
||||
<div class="min-w-0 text-zinc-600">Reduto</div>
|
||||
<div class="min-w-0 text-zinc-600">82%</div>
|
||||
<div class="min-w-0 text-zinc-600">há 2 dias</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] items-center gap-2 border-b border-zinc-100 px-4 py-3 text-sm"
|
||||
>
|
||||
<div class="min-w-0 truncate font-semibold text-zinc-900">Vitória da Conquista</div>
|
||||
<div class="min-w-0 text-zinc-600">Neutro</div>
|
||||
<div class="min-w-0 text-zinc-600">N1</div>
|
||||
<div class="min-w-0 text-zinc-600">Marginal</div>
|
||||
<div class="min-w-0 text-zinc-600">12%</div>
|
||||
<div class="min-w-0 text-zinc-600">há 9 dias</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] items-center gap-2 border-b border-zinc-100 px-4 py-3 text-sm"
|
||||
>
|
||||
<div class="min-w-0 truncate font-semibold text-zinc-900">Barreiras</div>
|
||||
<div class="min-w-0 text-zinc-600">Favorável</div>
|
||||
<div class="min-w-0 text-zinc-600">N2</div>
|
||||
<div class="min-w-0 text-zinc-600">Expansão</div>
|
||||
<div class="min-w-0 text-zinc-600">38%</div>
|
||||
<div class="min-w-0 text-zinc-600">há 4 dias</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[1.5fr_1.05fr_0.95fr_1.05fr_0.8fr_1.1fr] items-center gap-2 px-4 py-3 text-sm"
|
||||
>
|
||||
<div class="min-w-0 truncate font-semibold text-zinc-900">Juazeiro</div>
|
||||
<div class="min-w-0 text-zinc-600">Desfavorável</div>
|
||||
<div class="min-w-0 text-zinc-600">N1</div>
|
||||
<div class="min-w-0 text-zinc-600">Sem base</div>
|
||||
<div class="min-w-0 text-zinc-600">9%</div>
|
||||
<div class="min-w-0 text-zinc-600">há 12 dias</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-xs text-zinc-500">
|
||||
Mesma mesa de trabalho de um coordenador (menos apoiadores fora da carteira e demandas
|
||||
de que não é responsável) — ver regras no plano C141.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cena 3: detalhe de demanda em modo leitura -->
|
||||
<section
|
||||
data-shot="demanda-leitura"
|
||||
class="mt-6 overflow-hidden rounded-xl border border-zinc-800"
|
||||
>
|
||||
<div class="bg-zinc-100 px-8 py-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded bg-zinc-300 px-2 py-0.5 text-xs font-bold text-zinc-800"
|
||||
>Material de campanha</span
|
||||
>
|
||||
<span class="rounded bg-zinc-300 px-2 py-0.5 text-xs font-bold text-zinc-800"
|
||||
>Em aberto</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2 text-lg font-bold text-zinc-900">
|
||||
Cartazes para a caminhada do fim de semana
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">Ilhéus · liderança: Nome da liderança</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<div class="text-sm font-bold text-zinc-900">Descrição</div>
|
||||
<div class="mt-2 text-sm leading-relaxed text-zinc-600">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua.
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between border-t border-zinc-100 pt-3">
|
||||
<div class="text-xs text-zinc-500">Custo estimado</div>
|
||||
<div class="text-sm font-bold text-zinc-900">R$ 1.200,00</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<div class="text-xs text-zinc-500">Comprovantes</div>
|
||||
<div class="text-sm text-zinc-600">2 anexos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<div class="text-sm font-bold text-zinc-900">Histórico de status</div>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div class="flex gap-3">
|
||||
<div class="h-2 w-2 shrink-0 rounded-full bg-zinc-400"></div>
|
||||
<div class="text-xs text-zinc-600">
|
||||
<span class="font-bold text-zinc-800">Em aberto</span> — criada por Assessora de
|
||||
campo · 12 ago
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="h-2 w-2 shrink-0 rounded-full bg-zinc-400"></div>
|
||||
<div class="text-xs text-zinc-600">
|
||||
<span class="font-bold text-zinc-800">Encaminhada</span> — decidida por
|
||||
Coordenador Geral · 14 ago
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 rounded-md bg-zinc-100 px-3 py-2 text-[11px] leading-relaxed text-zinc-600"
|
||||
>
|
||||
Sem botões de transição, sem "registrar pedido", sem edição — só leitura do
|
||||
andamento.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
88
docs/plans/ui-respeita-perfil-permissao.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Interface respeita o perfil de permissão (assessor somente-leitura sem controles de edição; visão Tudo no escopo completo)
|
||||
|
||||
Status: aguardando execução
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #107
|
||||
Priority: P1
|
||||
Model: cursor-grok-4.5-medium
|
||||
Impeccable: B — passada de apresentação sobre as superfícies staff do `/campanha`
|
||||
Rascunho UI: docs/plans/ui-respeita-perfil-permissao-ui-draft.html
|
||||
Appetite: ~1–2 dias eng; telas passam a refletir o perfil (sem 403s à vista, escopo coerente)
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
O item irmão (perfis de permissão por assessor) entrega o modelo e o enforcement no servidor. Sem esta passada, um assessor "Somente leitura" ainda **vê** todos os botões, células autosave, FABs e sheets de edição — e cada toque morre com erro de permissão, o oposto do produto que a mesa espera. E um assessor que ganhou "Visão Tudo" precisa que listas, detalhes e mapa carreguem o estado inteiro de forma coerente (contagens, facetas, filtros), não meia-lista.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** o assessor com perfil restrito, operando o `/campanha` no celular e no desktop — não pode, não deve e não quer tentar editar; e o assessor de visão total, querendo a mesa de trabalho completa.
|
||||
- **Job principal:** a interface diz a verdade sobre o perfil: quem não edita não encontra edição; quem vê tudo vê tudo.
|
||||
- **Fluxo desejado:**
|
||||
1. Assessor "Carteira · Somente leitura" abre `/campanha/municipios`: células são leitura pura, sem chips editáveis, sem "Registrar atualização", sem FAB de ações — nenhum controle de escrita à vista, sem aviso: a tela simplesmente não oferece edição.
|
||||
2. Assessor "Tudo · Edita carteira" abre as mesmas listas: vê o estado inteiro (todos os municípios/lideranças) e edita apenas o que administra — o controle de edição aparece só onde a carteira alcança.
|
||||
3. Assessor "Carteira · Edita carteira": exatamente o comportamento de hoje (nada muda).
|
||||
- **Anti-goals de produto:** bloquear por mensagem de erro em vez de ocultar/desabilitar; cinza-fantasma que confunde o que é editável; redesenho de superfícies que não precisam mudar.
|
||||
|
||||
### Esboço de fluxo (B/C/D)
|
||||
|
||||
```text
|
||||
[login assessor] → [perfil da conta resolve o que renderizar]
|
||||
→ [visão: escopo completo ou carteira] × [edição: controles presentes ou ausentes]
|
||||
→ [nenhum caminho leva a 403; nenhum dado fora do escopo é oferecido]
|
||||
```
|
||||
|
||||
### Rascunho UI (B/C/D)
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- Assessor sem permissão de edição **não encontra nenhum controle de escrita** nas superfícies staff: células de autosave, chips removíveis, "Registrar atualização", FAB/ações rápidas, wizards, sheets e triggers de Popover — no desktop e no mobile. Sem aviso de modo leitura: a presença/ausência dos controles é a única linguagem _(decidido no gate: opção B)_.
|
||||
- Assessor "Visão Tudo" navega listas, detalhes e mapa no escopo completo, com contagens, facetas e filtros coerentes com o que ele vê.
|
||||
- Atalhos de escrita (ex.: busca global) seguem a mesma regra: destino de escrita some para quem não pode escrever; atalhos de navegação permanecem _(decidido no gate: opção A)_.
|
||||
- Nada muda para coordenador/candidato/liderança e para o assessor "Carteira · Edita carteira".
|
||||
- O enforcement de segurança não é relaxado em nenhum ponto: a apresentação acompanha o servidor, nunca o substitui.
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
- **Vou apresentar dados?** Não — passada de apresentação sobre superfícies existentes.
|
||||
- **Decisões desbloqueadas:** nenhuma decisão de dado nova.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** componentes `src/components/campaign/<domínio>/*` — células edit-in-place e cascas compartilhadas (`CampaignCellEditOverlay`, `CampaignListSheetProvider`, colunas por papel), triggers de autosave por lista, FAB/ações rápidas, botões "Registrar atualização" em municípios/atividades, controles de status em demandas; predicados client-safe de perfil em `src/lib/campaignRoles.ts` (ou o helper do item irmão); dashboards/mapa (`MunicipalityMapPanel` e loaders escopados).
|
||||
- **Precedente a olhar:** estados `readOnly` já existentes (`RelationChipCell`/`RelationOptionCell` com `readOnly`), a matriz por papel da célula de pessoas (C116: assessor edita só onde a carteira alcança, resto read-only), card mobile de municípios (B193).
|
||||
- **Risco de acoplamento:** o mapa e as listas já leem "escopo do ator" — cuidado para não duplicar lógica de escopo no cliente; o fragmento de escopo continua vivendo uma vez só (convenção P3-D).
|
||||
|
||||
## Dependências
|
||||
|
||||
- **C141** (modelo + enforcement + configuração) — este item só faz sentido depois do perfil existir e valer no servidor.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Qualquer mudança no modelo de permissão ou no enforcement (é o item irmão).
|
||||
- Redesenho de superfícies não relacionadas a permissão.
|
||||
- Estado de leitura para liderança (lockdown já é outra superfície, intocada).
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **"Vou esconder tudo com um flag e rezar."** A passada precisa achar **cada** caminho de escrita real (autosave, sheets, wizards, links para formulários). **Corte:** varrer por superfície com a matriz de papéis, como a célula de pessoas já faz; o que não for achado agora aparece no aceite do item.
|
||||
- **"Vou redesenhar a lista de municípios."** Tocar layout por tocar não é o pedido. **Corte:** manter layouts; mudar só presença/ausência de controles e o aviso de modo leitura.
|
||||
- **"Cinza-fantasma em tudo."** Desabilitar tudo visualmente polui mais do que ajuda. **Corte:** controles de edição **ocultos** para leitura; aviso discreto no topo.
|
||||
|
||||
## Decisões do gate (2026-08-19)
|
||||
|
||||
- **Modo leitura sem aviso** (opção B) — controles ocultos; presença/ausência de edição é a única linguagem.
|
||||
- **Atalhos de escrita seguem a regra** (opção A) — destino de escrita some para quem não pode escrever.
|
||||
|
||||
## Referências
|
||||
|
||||
- Item irmão: `docs/plans/permissao-granular-assessores.md` (C141) e `docs/plans/demandas-responsaveis.md` (C143 — a regra de demandas muda o que a demanda renderiza para quem não é responsável).
|
||||
- Rascunho UI (gate): `docs/plans/ui-respeita-perfil-permissao-ui-draft.html` + PNGs acima.
|
||||
- Para abrir primeiro: `src/components/campaign/shared/CampaignCellEditOverlay.tsx` (casca dos overlays de edição), `src/lib/campaignRoles.ts` (predicados por papel), a matriz por papel em `pessoas-*` (C116).
|
||||
- `AGENTS.md` — convenção P3-D do escopo de assessor.
|
||||
|
|
@ -230,4 +230,26 @@ curl -fsS -o /dev/null -X POST "$base/campanha/webauthn/login-options" \
|
|||
body="$(curl -fsS -X POST "$base/api/revalidate" -H "x-revalidate-secret: $REVALIDATE_SECRET" || true)"
|
||||
echo "$body" | grep -q '"revalidated":true' || smoke_fail "POST /api/revalidate did not confirm"
|
||||
|
||||
# --- post-deploy cleanup (INF3/F2) --------------------------------------
|
||||
# O deploy compila no homeserver e acumulava build cache + tags locais
|
||||
# antigas no disco raiz (incidente 19/08: 45G de cache + ~20 tags teqo
|
||||
# antigas). Best-effort e APÓS o smoke: falha de limpeza nunca falha um
|
||||
# deploy verde. O registry localhost:5000 preserva as imagens (rollback
|
||||
# intacto — runbook teqo-1313-deploy.md); removemos só as tags LOCAIS.
|
||||
# Fail-closed: sem revision no container, não remove nada além do cache.
|
||||
|
||||
say "post-deploy cleanup: build cache + tags locais antigas"
|
||||
docker builder prune -f >/dev/null 2>&1 || true
|
||||
|
||||
in_use="$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' teqo-1313 2>/dev/null || true)"
|
||||
if [ -n "$in_use" ]; then
|
||||
for img in $(docker images --format '{{.Repository}}:{{.Tag}}' | grep -E '^(localhost:5000/)?teqo-1313(-migrator)?:' || true); do
|
||||
tag="${img##*:}"
|
||||
[ "$tag" = "$in_use" ] && continue
|
||||
docker rmi "$img" >/dev/null 2>&1 || true
|
||||
done
|
||||
else
|
||||
say "cleanup: container sem revision — mantendo imagens locais"
|
||||
fi
|
||||
|
||||
say "deploy of $SHA complete: $(docker inspect -f '{{.Image}}' teqo-1313)"
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import { CampaignNewsletterCapture } from '@/components/CampaignNewsletterForm'
|
|||
* above the footer (decision 2026-08-19): visitor leaves name + WhatsApp to
|
||||
* follow the campaign, choosing the engagement level. The hero CTA anchors
|
||||
* here (`#novidades`, smooth scroll on the campaign-site container).
|
||||
*
|
||||
* S10 — passes the site-level Meta pixel to the form so a successful capture
|
||||
* fires exactly one `Lead` (the seam lives in the client form).
|
||||
*/
|
||||
export const CampaignNewsletterSection = () => (
|
||||
export const CampaignNewsletterSection = ({ pixelId }: { pixelId?: string }) => (
|
||||
<section
|
||||
id="novidades"
|
||||
aria-labelledby="novidades-title"
|
||||
|
|
@ -30,7 +33,7 @@ export const CampaignNewsletterSection = () => (
|
|||
</p>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<CampaignNewsletterCapture />
|
||||
<CampaignNewsletterCapture pixelId={pixelId} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { MetaPixel } from '@/components/MetaPixel'
|
||||
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
|
||||
import React from 'react'
|
||||
import '../styles.css'
|
||||
|
||||
export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||
const { children } = props
|
||||
|
||||
// S10 — the Meta base code + PageView for the campaign page. The read is
|
||||
// cached under `global_site-settings` and fails closed (no ID → nothing).
|
||||
const pixelId = await getCampaignHomeMetaPixelId()
|
||||
|
||||
// A root `(frontend)` trava o documento com `overflow-hidden`, então a home
|
||||
// é dona do container de scroll interno. `data-theme="campaign-site"` aplica
|
||||
// a paleta da campanha (vermelho PT + amarelo CTA) aos tokens `--pt-*`.
|
||||
|
|
@ -12,6 +18,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
data-theme="campaign-site"
|
||||
className="relative h-dvh w-full scroll-smooth overflow-y-auto bg-(--campaign-cream) text-(--campaign-ink) motion-reduce:scroll-auto"
|
||||
>
|
||||
{pixelId ? <MetaPixel pixelId={pixelId} /> : null}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { CampaignFooter } from '@/components/CampaignFooter'
|
|||
import { CampaignHero } from '@/components/CampaignHero'
|
||||
import { CampaignProblemCard } from '@/components/CampaignProblemCard'
|
||||
import { CampaignStorySection } from '@/components/CampaignStorySection'
|
||||
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
|
||||
import type { ReactNode } from 'react'
|
||||
import { CampaignNewsletterSection } from './CampaignNewsletterSection'
|
||||
|
||||
|
|
@ -110,6 +111,9 @@ const proofItems = [
|
|||
]
|
||||
|
||||
export default async function HomePage() {
|
||||
// S10 — site-level Meta pixel for the campaign home (cached read, fail-closed).
|
||||
const pixelId = await getCampaignHomeMetaPixelId()
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="w-full bg-white text-black">
|
||||
|
|
@ -231,7 +235,7 @@ export default async function HomePage() {
|
|||
</section>
|
||||
|
||||
<CampaignStorySection />
|
||||
<CampaignNewsletterSection />
|
||||
<CampaignNewsletterSection pixelId={pixelId ?? undefined} />
|
||||
</main>
|
||||
|
||||
<CampaignFooter />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { normalizeFacebookPixelId } from '@/lib/facebookPixel'
|
||||
import {
|
||||
FACEBOOK_PIXEL_ID_DESCRIPTION,
|
||||
normalizeFacebookPixelId,
|
||||
validateFacebookPixelId,
|
||||
} from '@/lib/facebookPixel'
|
||||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import { revalidateDocumentById } from '@/utilities/documents'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
|
@ -116,16 +120,9 @@ export const Petition: CollectionConfig<typeof slug> = {
|
|||
type: 'text',
|
||||
label: 'ID do Pixel do Meta (Facebook)',
|
||||
admin: {
|
||||
description:
|
||||
'Cole somente o ID numérico do Events Manager (ex.: 123456789012345), não o snippet HTML completo.',
|
||||
},
|
||||
validate: (value: string | null | undefined) => {
|
||||
if (!value) return true
|
||||
if (!normalizeFacebookPixelId(String(value))) {
|
||||
return 'Informe somente o ID numérico do Pixel (5 a 20 dígitos), sem HTML ou script.'
|
||||
}
|
||||
return true
|
||||
description: FACEBOOK_PIXEL_ID_DESCRIPTION,
|
||||
},
|
||||
validate: validateFacebookPixelId,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,13 +10,18 @@ import { Button } from '@/components/ui/button'
|
|||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Field, FieldDescription, FieldError, FieldGroup } from '@/components/ui/field'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { trackMetaLead } from '@/lib/facebookPixel'
|
||||
import { CampaignNewsletterInput, campaignNewsletterSchema } from '@/lib/schemas/campaignNewsletter'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
import { useState, useTransition } from 'react'
|
||||
import { FormProvider, SubmitHandler, useForm, useWatch } from 'react-hook-form'
|
||||
|
||||
/** S10 — stable `content_name` for the `Lead` fired on a successful capture. */
|
||||
const NEWSLETTER_LEAD_CONTENT_NAME = 'novidades-da-campanha'
|
||||
|
||||
interface CampaignNewsletterFormProps {
|
||||
pixelId?: string
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
|
|
@ -24,9 +29,9 @@ interface CampaignNewsletterFormProps {
|
|||
* S9 — public capture of campaign "novidades" on the home: name + WhatsApp
|
||||
* required, email/state/city/comment optional, engagement-level toggle
|
||||
* pre-selected ("Quero fazer parte do time"). Success swaps the form for an
|
||||
* in-place confirmation (the seam where S10 will fire `Lead`).
|
||||
* in-place confirmation (the seam where S10 fires `Lead`).
|
||||
*/
|
||||
const CampaignNewsletterForm = ({ onSubmit }: CampaignNewsletterFormProps) => {
|
||||
const CampaignNewsletterForm = ({ pixelId, onSubmit }: CampaignNewsletterFormProps) => {
|
||||
const methods = useForm<CampaignNewsletterInput>({
|
||||
resolver: zodResolver(campaignNewsletterSchema),
|
||||
defaultValues: {
|
||||
|
|
@ -46,6 +51,18 @@ const CampaignNewsletterForm = ({ onSubmit }: CampaignNewsletterFormProps) => {
|
|||
startTransition(async () => {
|
||||
try {
|
||||
await submitCampaignNewsletter(input)
|
||||
// S10 — exactly one `Lead` per successful capture, never on page load
|
||||
// (petition precedent). The inner guard keeps a tracking hiccup (e.g.
|
||||
// `crypto.randomUUID` on a non-secure context) from turning an
|
||||
// already-committed capture into the error UI; `trackMetaLead`
|
||||
// no-ops safely without fbq.
|
||||
if (pixelId) {
|
||||
try {
|
||||
trackMetaLead(pixelId, NEWSLETTER_LEAD_CONTENT_NAME, crypto.randomUUID())
|
||||
} catch {
|
||||
// tracking is invisible to the visitor
|
||||
}
|
||||
}
|
||||
methods.reset()
|
||||
onSubmit()
|
||||
} catch (error) {
|
||||
|
|
@ -170,12 +187,12 @@ const CampaignNewsletterSuccess = () => (
|
|||
)
|
||||
|
||||
/** S9 — wrapper owning the form ↔ success swap on the home section. */
|
||||
export const CampaignNewsletterCapture = () => {
|
||||
export const CampaignNewsletterCapture = ({ pixelId }: { pixelId?: string }) => {
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
return submitted ? (
|
||||
<CampaignNewsletterSuccess />
|
||||
) : (
|
||||
<CampaignNewsletterForm onSubmit={() => setSubmitted(true)} />
|
||||
<CampaignNewsletterForm pixelId={pixelId} onSubmit={() => setSubmitted(true)} />
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import {
|
||||
FACEBOOK_PIXEL_ID_DESCRIPTION,
|
||||
normalizeFacebookPixelId,
|
||||
validateFacebookPixelId,
|
||||
} from '@/lib/facebookPixel'
|
||||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import { revalidateGlobal } from '@/utilities/globals'
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
|
@ -19,6 +24,14 @@ export const SiteSettings: GlobalConfig = {
|
|||
update: payloadAdminOnly,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ data }) => {
|
||||
if (data?.tracking?.facebookPixelId != null) {
|
||||
data.tracking.facebookPixelId = normalizeFacebookPixelId(data.tracking.facebookPixelId)
|
||||
}
|
||||
return data
|
||||
},
|
||||
],
|
||||
afterChange: [revalidate],
|
||||
},
|
||||
fields: [
|
||||
|
|
@ -82,6 +95,22 @@ export const SiteSettings: GlobalConfig = {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'tracking',
|
||||
type: 'group',
|
||||
label: 'Rastreamento / Ads',
|
||||
fields: [
|
||||
{
|
||||
name: 'facebookPixelId',
|
||||
type: 'text',
|
||||
label: 'ID do Pixel do Meta (Facebook)',
|
||||
admin: {
|
||||
description: FACEBOOK_PIXEL_ID_DESCRIPTION,
|
||||
},
|
||||
validate: validateFacebookPixelId,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
versions: {
|
||||
drafts: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
const FACEBOOK_PIXEL_ID_PATTERN = /^\d{5,20}$/
|
||||
|
||||
// Shared admin copy: every surface that takes a Pixel ID must say the same
|
||||
// thing — only the numeric ID, never the full snippet (drift would invite XSS).
|
||||
export const FACEBOOK_PIXEL_ID_DESCRIPTION =
|
||||
'Cole somente o ID numérico do Events Manager (ex.: 123456789012345), não o snippet HTML completo.'
|
||||
|
||||
export function isValidFacebookPixelId(value: string | null | undefined): boolean {
|
||||
if (value == null || value === '') return false
|
||||
return FACEBOOK_PIXEL_ID_PATTERN.test(value)
|
||||
|
|
@ -12,6 +17,16 @@ export function normalizeFacebookPixelId(value: string | null | undefined): stri
|
|||
return isValidFacebookPixelId(trimmed) ? trimmed : null
|
||||
}
|
||||
|
||||
// Payload field validation shared by every admin surface that takes a Pixel ID
|
||||
// (petition + site settings): optional field, digits only when present.
|
||||
export function validateFacebookPixelId(value: string | null | undefined): true | string {
|
||||
if (!value) return true
|
||||
if (!normalizeFacebookPixelId(String(value))) {
|
||||
return 'Informe somente o ID numérico do Pixel (5 a 20 dígitos), sem HTML ou script.'
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
fbq?: (
|
||||
|
|
|
|||
13776
src/migrations/20260819_213947_add_site_settings_facebook_pixel_id.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "site_settings" ADD COLUMN "tracking_facebook_pixel_id" varchar;
|
||||
ALTER TABLE "_site_settings_v" ADD COLUMN "version_tracking_facebook_pixel_id" varchar;`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "site_settings" DROP COLUMN "tracking_facebook_pixel_id";
|
||||
ALTER TABLE "_site_settings_v" DROP COLUMN "version_tracking_facebook_pixel_id";`)
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ import * as migration_20260811_234822_add_state_deputy_ballot_name from './20260
|
|||
import * as migration_20260818_111748_add_social_feed_settings from './20260818_111748_add_social_feed_settings';
|
||||
import * as migration_20260818_192948_add_instagram_social_feed_fields from './20260818_192948_add_instagram_social_feed_fields';
|
||||
import * as migration_20260819_192605_add_campaign_newsletter_capture from './20260819_192605_add_campaign_newsletter_capture';
|
||||
import * as migration_20260819_213947_add_site_settings_facebook_pixel_id from './20260819_213947_add_site_settings_facebook_pixel_id';
|
||||
import * as migration_20260819_214755_add_campaign_user_advisor_permission_profile from './20260819_214755_add_campaign_user_advisor_permission_profile';
|
||||
import * as migration_20260819_233503_add_campaign_demand_responsibles from './20260819_233503_add_campaign_demand_responsibles';
|
||||
import * as migration_20260820_003426_add_instagram_sync_status from './20260820_003426_add_instagram_sync_status';
|
||||
|
|
@ -353,9 +354,14 @@ export const migrations = [
|
|||
name: '20260818_192948_add_instagram_social_feed_fields',
|
||||
},
|
||||
{
|
||||
up: migration_20260819_192605_add_campaign_newsletter_capture.up,
|
||||
up: migration_20260819_192605_add_campaign_newsletter_capture.up,
|
||||
down: migration_20260819_192605_add_campaign_newsletter_capture.down,
|
||||
name: '20260819_192605_add_campaign_newsletter_capture'
|
||||
name: '20260819_192605_add_campaign_newsletter_capture',
|
||||
},
|
||||
{
|
||||
up: migration_20260819_213947_add_site_settings_facebook_pixel_id.up,
|
||||
down: migration_20260819_213947_add_site_settings_facebook_pixel_id.down,
|
||||
name: '20260819_213947_add_site_settings_facebook_pixel_id',
|
||||
},
|
||||
{
|
||||
up: migration_20260819_214755_add_campaign_user_advisor_permission_profile.up,
|
||||
|
|
@ -365,11 +371,11 @@ up: migration_20260819_192605_add_campaign_newsletter_capture.up,
|
|||
{
|
||||
up: migration_20260819_233503_add_campaign_demand_responsibles.up,
|
||||
down: migration_20260819_233503_add_campaign_demand_responsibles.down,
|
||||
name: '20260819_233503_add_campaign_demand_responsibles'
|
||||
name: '20260819_233503_add_campaign_demand_responsibles',
|
||||
},
|
||||
{
|
||||
up: migration_20260820_003426_add_instagram_sync_status.up,
|
||||
down: migration_20260820_003426_add_instagram_sync_status.down,
|
||||
name: '20260820_003426_add_instagram_sync_status'
|
||||
name: '20260820_003426_add_instagram_sync_status',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2293,6 +2293,12 @@ export interface SiteSetting {
|
|||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
tracking?: {
|
||||
/**
|
||||
* Cole somente o ID numérico do Events Manager (ex.: 123456789012345), não o snippet HTML completo.
|
||||
*/
|
||||
facebookPixelId?: string | null;
|
||||
};
|
||||
_status?: ('draft' | 'published') | null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
|
|
@ -2487,6 +2493,11 @@ export interface SiteSettingsSelect<T extends boolean = true> {
|
|||
enabled?: T;
|
||||
id?: T;
|
||||
};
|
||||
tracking?:
|
||||
| T
|
||||
| {
|
||||
facebookPixelId?: T;
|
||||
};
|
||||
_status?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
|
|
|
|||
15
src/utilities/campaignHomeTracking.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import 'server-only'
|
||||
|
||||
import { normalizeFacebookPixelId } from '@/lib/facebookPixel'
|
||||
import { getCachedGlobal } from '@/utilities/globalReads'
|
||||
|
||||
/**
|
||||
* S10 — the Meta pixel configured in `SiteSettings.tracking` for the public
|
||||
* campaign page. Cached under `global_site-settings`, so an admin edit of the
|
||||
* global already revalidates the home (the `afterChange` hook busts the tag).
|
||||
* Fail-closed: no configured ID → null → nothing renders on the page.
|
||||
*/
|
||||
export const getCampaignHomeMetaPixelId = async (): Promise<string | null> => {
|
||||
const settings = await getCachedGlobal('site-settings')()
|
||||
return normalizeFacebookPixelId(settings.tracking?.facebookPixelId)
|
||||
}
|
||||
97
tests/e2e/campaignHomePixel.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import type { APIRequestContext } from '@playwright/test'
|
||||
|
||||
import { adminHeaders } from '../helpers/adminApi'
|
||||
import { seedTestUser } from '../helpers/seedUser'
|
||||
import { expect, test } from './fixtures/e2eTest'
|
||||
|
||||
/**
|
||||
* S10 — the Meta pixel on the public campaign page: configured through
|
||||
* `SiteSettings.tracking.facebookPixelId` (admin REST), the home renders the
|
||||
* base code (inline `next/script` + `fbq` init) and nothing at all without a
|
||||
* configured ID (fail-closed). The external `fbevents.js` fetch is fulfilled
|
||||
* locally by the shared e2e guard (fixtures/e2eTest.ts), so no spec ever
|
||||
* depends on the Meta CDN; the base code itself is inline. The `Lead` wiring
|
||||
* (successful S9 capture → exactly one queued `fbq` Lead) is pinned by the
|
||||
* unit spec `campaignNewsletterLead.unit.spec.tsx` — a real submission here
|
||||
* would share the `campanha-novidades` consent key with the parallel
|
||||
* `campaignNewsletter` spec's purge/create lifecycle. Serial mode (precedent
|
||||
* `campaignNearestMunicipality`): the tests share one worker — a parallel
|
||||
* split would race two `seedTestUser` beforeAlls.
|
||||
*/
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.describe('Campaign home Meta pixel', () => {
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
|
||||
const runSuffix = Date.now()
|
||||
// Valid 15-digit numeric ID (same class the admin validation accepts).
|
||||
const PIXEL_ID = '123456789012345'
|
||||
|
||||
// `_status: 'published'` publishes through the global's draft pipeline, same
|
||||
// pattern the frontend spec uses for versioned documents.
|
||||
const setSitePixel = async (
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
pixelId: string | null,
|
||||
) => {
|
||||
// Insurance against the same-user concurrent-login session race with
|
||||
// specs that do NOT route their login through the advisory-locked
|
||||
// `adminHeaders` helper (e.g. the frontend spec's inline logins): a 403
|
||||
// means the session row was lost to another worker — re-login mints a
|
||||
// fresh one. The locked helper already serializes the specs that use it.
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const response = await request.post(`${baseURL}/api/globals/site-settings`, {
|
||||
headers,
|
||||
data: { tracking: { facebookPixelId: pixelId }, _status: 'published' },
|
||||
})
|
||||
if (response.ok()) return
|
||||
if (response.status() === 403 && attempt === 0) {
|
||||
headers = await adminHeaders(request, baseURL)
|
||||
continue
|
||||
}
|
||||
throw new Error(`setSitePixel(${pixelId}) failed with ${response.status()}`)
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await seedTestUser()
|
||||
// Self-healing lifecycle (S9 precedent): an aborted run could leave the
|
||||
// pixel behind and poison the fail-closed assertion — purge first.
|
||||
const headers = await adminHeaders(request, baseURL)
|
||||
await setSitePixel(request, headers, null)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
const headers = await adminHeaders(request, baseURL).catch(() => undefined)
|
||||
if (headers) {
|
||||
await setSitePixel(request, headers, null)
|
||||
}
|
||||
})
|
||||
|
||||
test('renders nothing without a configured pixel ID', async ({ page }) => {
|
||||
// Fresh load (unique query param misses every HTTP/router cache entry —
|
||||
// the content-section trick); the app ignores unknown params. The
|
||||
// fail-closed read comes from the global, which beforeAll just reset.
|
||||
await page.goto(`/?e2e=${runSuffix}-none`)
|
||||
await expect(page.locator('script[id^="meta-pixel-"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('renders the base code when the site settings carry a valid ID', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const headers = await adminHeaders(request, baseURL)
|
||||
// The REST update busts `global_site-settings` via afterChange BEFORE the
|
||||
// goto, so the fresh render carries the pixel (deterministic, no cache race).
|
||||
await setSitePixel(request, headers, PIXEL_ID)
|
||||
await page.goto(`/?e2e=${runSuffix}-set`)
|
||||
|
||||
const pixelScript = page.locator(`script#meta-pixel-${PIXEL_ID}`)
|
||||
await expect(pixelScript).toBeAttached()
|
||||
|
||||
// The base code defines `window.fbq` synchronously (queue-based no-op
|
||||
// until fbevents.js processes it) — the seam `trackMetaLead` uses.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => typeof (window as { fbq?: unknown }).fbq === 'function'))
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import type { APIRequestContext } from '@playwright/test'
|
||||
|
||||
import { CAMPAIGN_NEWSLETTER_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import { seedTestUser, testUser } from '../helpers/seedUser'
|
||||
import { adminHeaders } from '../helpers/adminApi'
|
||||
import { seedTestUser } from '../helpers/seedUser'
|
||||
import { expect, test } from './fixtures/e2eTest'
|
||||
|
||||
/**
|
||||
|
|
@ -22,15 +23,6 @@ test.describe('Campaign home novidades capture', () => {
|
|||
|
||||
let consentID: number | undefined
|
||||
|
||||
const adminHeaders = async (request: APIRequestContext): Promise<Record<string, string>> => {
|
||||
const login = await request.post(`${baseURL}/api/users/login`, {
|
||||
data: { email: testUser.email, password: testUser.password },
|
||||
})
|
||||
expect(login.ok()).toBeTruthy()
|
||||
const { token } = await login.json()
|
||||
return { cookie: `payload-token=${token}` }
|
||||
}
|
||||
|
||||
const createNewsletterConsent = async (
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
|
|
@ -130,12 +122,12 @@ test.describe('Campaign home novidades capture', () => {
|
|||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await seedTestUser()
|
||||
const headers = await adminHeaders(request)
|
||||
const headers = await adminHeaders(request, baseURL)
|
||||
await createNewsletterConsent(request, headers)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
const headers = await adminHeaders(request).catch(() => undefined)
|
||||
const headers = await adminHeaders(request, baseURL).catch(() => undefined)
|
||||
if (headers) {
|
||||
await cleanup(request, headers)
|
||||
}
|
||||
|
|
@ -173,7 +165,7 @@ test.describe('Campaign home novidades capture', () => {
|
|||
await expect(flagsLink).toHaveAttribute('href', '#bandeiras')
|
||||
|
||||
// The record is visible to the admin with the level choice.
|
||||
const headers = await adminHeaders(request)
|
||||
const headers = await adminHeaders(request, baseURL)
|
||||
const contacts = await request.get(
|
||||
`${baseURL}/api/contact?where[phones.value][equals]=${successPhone}&limit=1&depth=0`,
|
||||
{ headers },
|
||||
|
|
|
|||
|
|
@ -107,6 +107,14 @@ export const test = base.extend<E2EFailureGuardFixtures>({
|
|||
// player loading — same spirit as the S2/S3 feed stubs).
|
||||
await context.route('https://www.youtube-nocookie.com/**', (route) => route.abort())
|
||||
|
||||
// S10 — the campaign-home pixel spec publishes a Meta Pixel ID into the
|
||||
// shared test DB, so concurrent specs may render the home with the base
|
||||
// code. Fulfill the external script locally (never a real request to
|
||||
// Meta): a blocked network would trip the console-error guard below.
|
||||
await context.route('https://connect.facebook.net/**', (route) =>
|
||||
route.fulfill({ body: '', contentType: 'application/javascript' }),
|
||||
)
|
||||
|
||||
await use()
|
||||
|
||||
page.off('console', onConsole)
|
||||
|
|
|
|||
41
tests/helpers/adminApi.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { APIRequestContext } from '@playwright/test'
|
||||
import { Client } from 'pg'
|
||||
|
||||
import { testUser } from './seedUser'
|
||||
|
||||
// Arbitrary fixed key — every spec logs the same test user in through this
|
||||
// helper, so one serialization point covers the whole suite.
|
||||
const ADMIN_LOGIN_LOCK_KEY = 727_001
|
||||
|
||||
/**
|
||||
* Logs the shared test user in and returns the `payload-token` cookie.
|
||||
*
|
||||
* Serialized by a Postgres advisory lock: Payload keeps auth sessions as a
|
||||
* read-modify-write array on the user document (the campaign WebAuthn note
|
||||
* documents the exact class), so two parallel workers logging the same
|
||||
* `dev@payloadcms.com` in concurrently can lose one session row — the loser's
|
||||
* token then resolves to nobody and every admin request answers 403. The
|
||||
* lock makes the read-modify-write exclusive; a waiting worker logs in after
|
||||
* the winner, keeping every session.
|
||||
*/
|
||||
export const adminHeaders = async (
|
||||
request: APIRequestContext,
|
||||
baseURL: string,
|
||||
): Promise<Record<string, string>> => {
|
||||
const client = new Client({ connectionString: process.env.DATABASE_URL })
|
||||
await client.connect()
|
||||
try {
|
||||
await client.query('SELECT pg_advisory_lock($1)', [ADMIN_LOGIN_LOCK_KEY])
|
||||
const login = await request.post(`${baseURL}/api/users/login`, {
|
||||
data: { email: testUser.email, password: testUser.password },
|
||||
})
|
||||
if (!login.ok()) {
|
||||
throw new Error(`admin login failed with ${login.status()}`)
|
||||
}
|
||||
const { token } = await login.json()
|
||||
return { cookie: `payload-token=${token}` }
|
||||
} finally {
|
||||
await client.query('SELECT pg_advisory_unlock($1)', [ADMIN_LOGIN_LOCK_KEY]).catch(() => {})
|
||||
await client.end()
|
||||
}
|
||||
}
|
||||
|
|
@ -10,29 +10,30 @@ export const testUser = {
|
|||
|
||||
/**
|
||||
* Seeds a test user for e2e admin tests.
|
||||
*
|
||||
* Create-if-missing on purpose: specs run fully parallel across workers and
|
||||
* share this one user, so a delete-then-create left a window where (a) a
|
||||
* concurrent `create` hit the unique email, and (b) the user — and every
|
||||
* already-minted admin token — briefly vanished. The credentials are constant
|
||||
* (testUser), so a row created by any worker is identical to the one another
|
||||
* would create: the unique-conflict loser simply proceeds, and no password
|
||||
* reset ever invalidates a live session.
|
||||
*/
|
||||
export async function seedTestUser(): Promise<void> {
|
||||
assertTestDatabase(process.env.DATABASE_URL)
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
// Delete existing test user if any
|
||||
await payload.delete({
|
||||
collection: 'users',
|
||||
where: {
|
||||
email: {
|
||||
equals: testUser.email,
|
||||
try {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
...testUser,
|
||||
roles: ['admin'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Create fresh test user
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
...testUser,
|
||||
roles: ['admin'],
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch {
|
||||
// A parallel worker already seeded the shared user; nothing to reset.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ vi.mock('@/components/CampaignNewsletterForm', () => ({
|
|||
CampaignNewsletterCapture: () => null,
|
||||
}))
|
||||
|
||||
// S10 — the section reads the site-settings global (server-only, Payload DB);
|
||||
// the unit env has no database, and the pixel behavior is e2e-covered.
|
||||
vi.mock('@/utilities/campaignHomeTracking', () => ({
|
||||
getCampaignHomeMetaPixelId: async () => null,
|
||||
}))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Campaign home', () => {
|
||||
|
|
|
|||
98
tests/unit/campaignNewsletterLead.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CampaignNewsletterCapture } from '@/components/CampaignNewsletterForm'
|
||||
|
||||
/**
|
||||
* S10 — the `Lead` wiring of the S9 capture form: a successful submission
|
||||
* with a configured pixel fires exactly one `trackMetaLead` (content_name
|
||||
* `novidades-da-campanha`, UUID eventID); without a pixel, or when the
|
||||
* submission fails, nothing fires. The e2e covers the base-code render; the
|
||||
* real submission stays out of e2e on purpose (the `campanha-novidades`
|
||||
* consent key is shared with the parallel `campaignNewsletter` spec).
|
||||
*/
|
||||
const submitCampaignNewsletterMock = vi.fn()
|
||||
const trackMetaLeadMock = vi.fn()
|
||||
|
||||
vi.mock('@/app/(frontend)/actions/submitCampaignNewsletter', () => ({
|
||||
submitCampaignNewsletter: (...args: unknown[]) => submitCampaignNewsletterMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/facebookPixel', () => ({
|
||||
trackMetaLead: (...args: unknown[]) => trackMetaLeadMock(...args),
|
||||
}))
|
||||
|
||||
const PIXEL_ID = '123456789012345'
|
||||
|
||||
beforeAll(() => {
|
||||
// Radix comboboxes (StateSelect/CitySelect) need ResizeObserver in jsdom.
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const fillAndSubmit = async () => {
|
||||
// FormInput propagates to react-hook-form through the native `input` event.
|
||||
fireEvent.input(screen.getByPlaceholderText('Nome completo*'), {
|
||||
target: { value: 'Maria Teste' },
|
||||
})
|
||||
fireEvent.input(screen.getByPlaceholderText('WhatsApp (com DDD)*'), {
|
||||
target: { value: '71987654321' },
|
||||
})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'QUERO RECEBER NOVIDADES' }))
|
||||
})
|
||||
}
|
||||
|
||||
describe('Campaign newsletter Lead wiring', () => {
|
||||
beforeEach(() => {
|
||||
submitCampaignNewsletterMock.mockReset()
|
||||
trackMetaLeadMock.mockReset()
|
||||
submitCampaignNewsletterMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('fires exactly one Lead on a successful capture with a configured pixel', async () => {
|
||||
render(<CampaignNewsletterCapture pixelId={PIXEL_ID} />)
|
||||
await fillAndSubmit()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Inscrição confirmada' })).toBeTruthy()
|
||||
})
|
||||
expect(trackMetaLeadMock).toHaveBeenCalledTimes(1)
|
||||
expect(trackMetaLeadMock).toHaveBeenCalledWith(
|
||||
PIXEL_ID,
|
||||
'novidades-da-campanha',
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it('never fires a Lead without a configured pixel', async () => {
|
||||
render(<CampaignNewsletterCapture />)
|
||||
await fillAndSubmit()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Inscrição confirmada' })).toBeTruthy()
|
||||
})
|
||||
expect(trackMetaLeadMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never fires a Lead when the capture fails', async () => {
|
||||
submitCampaignNewsletterMock.mockRejectedValue(new Error('boom'))
|
||||
render(<CampaignNewsletterCapture pixelId={PIXEL_ID} />)
|
||||
await fillAndSubmit()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Falha ao enviar. Tente novamente.')).toBeTruthy()
|
||||
})
|
||||
expect(trackMetaLeadMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -433,6 +433,9 @@ describe('src/utilities top-level is pinned', () => {
|
|||
'campaignFormFields.ts',
|
||||
'campaignGeolocation.ts',
|
||||
'campaignGoals.ts',
|
||||
// S10 — campaign-home Meta pixel read (site-settings global, fail-closed);
|
||||
// shared by the home layout (PageView) and the page (Lead prop).
|
||||
'campaignHomeTracking.ts',
|
||||
'campaignInvite.ts',
|
||||
'campaignInviteCreation.ts',
|
||||
'campaignInviteOrigin.ts',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
isValidFacebookPixelId,
|
||||
normalizeFacebookPixelId,
|
||||
trackMetaLead,
|
||||
validateFacebookPixelId,
|
||||
} from '@/lib/facebookPixel'
|
||||
|
||||
describe('facebookPixel', () => {
|
||||
|
|
@ -35,6 +36,24 @@ describe('facebookPixel', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('validateFacebookPixelId', () => {
|
||||
it('accepts empty values (optional field)', () => {
|
||||
expect(validateFacebookPixelId('')).toBe(true)
|
||||
expect(validateFacebookPixelId(null)).toBe(true)
|
||||
expect(validateFacebookPixelId(undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts numeric IDs between 5 and 20 digits', () => {
|
||||
expect(validateFacebookPixelId('123456789012345')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-numeric and out-of-range values with the admin message', () => {
|
||||
const message = 'Informe somente o ID numérico do Pixel (5 a 20 dígitos), sem HTML ou script.'
|
||||
expect(validateFacebookPixelId('1234')).toBe(message)
|
||||
expect(validateFacebookPixelId('<script>alert(1)</script>')).toBe(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('trackMetaLead', () => {
|
||||
it('no-ops when fbq is unavailable', () => {
|
||||
expect(() => trackMetaLead('123456789012345', 'Petição teste', 'event-1')).not.toThrow()
|
||||
|
|
|
|||