Compare commits
2 commits
main
...
cheapest-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
733982d29a | ||
|
|
4a9cdf08c8 |
|
|
@ -7,7 +7,7 @@
|
|||
# shell que o chamou.
|
||||
#
|
||||
# No terminal (esta função), o script também imprime a diretiva `launch
|
||||
# opencode <dir> --model deepseek/deepseek-v4-flash --auto [--prompt "…"]` (OPS26):
|
||||
# opencode <dir> --model cheapestinference/deepseek-v4-flash --auto [--prompt "…"]` (OPS26):
|
||||
# a função executa o cd e então a linha (tokenizada por xargs — honra as aspas
|
||||
# do prompt, nunca eval), e o TUI do opencode abre no worktree — `next` com
|
||||
# `/work-issue --issue <N>` já enviado (OPS33: a Issue claimada vai no prompt),
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.op
|
|||
|
||||
**Default-go:** `next`, `plan` e `new` imprimem `cd <dir>` na última linha **por padrão**; `--stay` suprime; `--go` explícito continua aceito como no-op. No terminal interativo, use a função `worktree()` de **`.agents/shell/worktree.sh`** (uma linha de `source` no profile) para o `cd` ser aplicado de verdade no shell que te chamou — node não consegue mudar o cwd do shell pai. `kill` também imprime `cd <main>` no fim (a sessão nunca fica num diretório destruído).
|
||||
|
||||
**Launch do opencode (OPS26 + OPS33 + OPS31):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new` imprimem também a diretiva `launch opencode <dir> --model deepseek/deepseek-v4-flash --auto [--prompt "/work-issue --issue <N>"]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue <N>` já enviado (a Issue claimada vai no prompt; a skill lê o resto do Forgejo); `plan` abre com `/plan-issue` já enviado (OPS31: o TUI já cai no fluxo de planejamento); `new` abre sem `--prompt` ("apenas conversar"). Sem `exec`: sair do opencode volta ao shell no worktree. Presets são constantes em `scripts/lib/worktree.mjs`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.**
|
||||
**Launch do opencode (OPS26 + OPS33 + OPS31):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new` imprimem também a diretiva `launch opencode <dir> --model cheapestinference/deepseek-v4-flash --auto [--prompt "/work-issue --issue <N>"]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue <N>` já enviado (a Issue claimada vai no prompt; a skill lê o resto do Forgejo); `plan` abre com `/plan-issue` já enviado (OPS31: o TUI já cai no fluxo de planejamento); `new` abre sem `--prompt` ("apenas conversar"). Sem `exec`: sair do opencode volta ao shell no worktree. Presets são constantes em `scripts/lib/worktree.mjs`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.**
|
||||
|
||||
## Fluxo quando invocado como skill (agentes que não têm opencode)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
description: Transforma ideias humanas em Issues GitHub + planos de intenção em docs/plans/ (com gate e registro)
|
||||
model: deepseek/deepseek-v4-flash
|
||||
model: cheapestinference/deepseek-v4-flash
|
||||
---
|
||||
|
||||
Carregue a skill `plan-issue` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta. A fonte canônica é `.agents/skills/plan-issue/SKILL.md` — não a transcreva nem recrie o fluxo.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
description: Executa uma Issue já claimada de ponta a ponta com supervisão humana (plano de implementação → pausa → execução → simplify → débitos → PR)
|
||||
model: deepseek/deepseek-v4-flash
|
||||
model: cheapestinference/deepseek-v4-flash
|
||||
---
|
||||
|
||||
Carregue a skill `work-issue` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta. A fonte canônica é `.agents/skills/work-issue/SKILL.md` — não a transcreva nem recrie o fluxo.
|
||||
|
|
|
|||
|
|
@ -4,10 +4,6 @@ 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.
|
||||
|
||||
**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. O diagnóstico mostrou que a premissa do plano (fix nunca tinha chegado a prod) estava errada em dois pontos: (1) 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`) regenerou o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo `getFromImportMap: PayloadComponent not found` (admin em branco de novo); (2) o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71) — o GitHub tinha o fix + OPS71, mas não as features S8 ("Nossa história") e S9 (captura de novidades) que produção já rodava. A entrega (PR #751): re-home do S8+S9 no GitHub main (cherry-pick `8d2352c0`+`488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); deploy via `deploy.yml` no runner self-hosted do homeserver (`dba75ff8`), após 2 "stale run" por merges paralelos no verify; migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: revision `dba75ff8`, healthy, **log sem `getFromImportMap`**, `/admin` renderizando o login (2 inputs + botão, título "Login - Payload", 0 erros de console), home mantendo S8/S9. Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições: regeneração do importMap sem envs `S3_*` re-órfão o handler (classe do guard OPS70/#87); divergência de plataformas pós-cutover (workspace homeserver ainda aponta Forgejo local; S10 pixel pendente de re-home).
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
**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).
|
||||
|
|
@ -1 +0,0 @@
|
|||
**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).
|
||||
112
docs/ops/instagram-feed-testing-guide.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Guide — Testar o feed do Instagram na home pública (S3)
|
||||
|
||||
Como o feed do Instagram chega à home e como validar cada peça do quebra-cabeça
|
||||
quando "os posts não aparecem".
|
||||
|
||||
## Como funciona (o caminho que um post percorre)
|
||||
|
||||
1. A home de campanha renderiza o board **"Acompanhe de perto"**
|
||||
(`CampaignContentSection`). Ele só aparece se houver **algum** card visível
|
||||
(artigos + YouTube + Instagram).
|
||||
2. O render chama `getInstagramFeed()` (`src/utilities/socialFeed/instagramFeedView.ts`),
|
||||
cacheado por 5 min na tag `social-feed`.
|
||||
3. Ele lê a global **Configurações → Feed de redes sociais**
|
||||
(`/admin/globals/social-feed-settings`). Se `enabled` **e** `instagramEnabled`
|
||||
**e** token **e** user id estiverem preenchidos, chama a Instagram Graph API
|
||||
(`GET /{userId}` para o username e `GET /{userId}/media` para os posts).
|
||||
4. Sucesso → grava o snapshot cru na global e o status de sincronização; o board
|
||||
mostra os posts elegíveis (menos os marcados em "Itens excluídos").
|
||||
5. Falha → grava o **motivo** no status e o board cai no último snapshot (ou fica
|
||||
sem cards IG). **A home nunca quebra** — o erro é invisível na home, ele vive
|
||||
no painel de status do admin.
|
||||
|
||||
## O sinal mais rápido: o painel de status (admin)
|
||||
|
||||
Em `/admin` → **Configurações → Feed de redes sociais**, o bloco **"Instagram —
|
||||
estado da sincronização"** é o diagnóstico em 1 clique:
|
||||
|
||||
| Painel mostra | Significado | Ação |
|
||||
| --------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| **Sincronizado · há X min · N posts** | Credenciais aceitas; cards devem estar na home | Conferir a home (cache de 5 min) |
|
||||
| **Falha na última sincronização** | A Graph API recusou; o texto diz o motivo | Seguir a correção do texto e clicar **Tentar sincronizar de novo** |
|
||||
| **Instagram ainda não configurado** | Falta token ou user id | Preencher os dois campos |
|
||||
| **Aguardando a primeira sincronização** | Configurado, nunca tentou | Clicar **Tentar sincronizar de novo** |
|
||||
|
||||
O status é atualizado: no salvar da global (quando as credenciais mudam), em cada
|
||||
render da home e no botão **Tentar sincronizar de novo**.
|
||||
|
||||
## Validar o token fora do admin
|
||||
|
||||
```bash
|
||||
# Se responder com username + user_id, o token é válido para a Graph API do IG:
|
||||
curl -s "https://graph.instagram.com/me?fields=user_id,username&access_token=SEU_TOKEN"
|
||||
|
||||
# Testa o refresh (só token emitido pelo Instagram Login passa):
|
||||
curl -s "https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=SEU_TOKEN"
|
||||
```
|
||||
|
||||
- `/me` com `OAuthException` → token inválido/expirado **ou emitido via Facebook
|
||||
Login** (a Graph API do Instagram só aceita tokens do Instagram Login).
|
||||
- `/me` ok mas o `user_id` devolvido não bate com o campo **"ID do usuário"** →
|
||||
corrigir o campo.
|
||||
|
||||
## Testar a home pública
|
||||
|
||||
1. Conferir o painel de status primeiro (acima).
|
||||
2. Abrir a home e procurar o board "Acompanhe de perto". Os cards do Instagram
|
||||
têm badge **Instagram**, capa quadrada (1:1), legenda e data, e abrem o post
|
||||
em nova aba (`instagram.com`).
|
||||
3. **Lembrete de cache:** o feed é cacheado por 5 min. Depois de salvar a global
|
||||
no admin o cache é invalidado sozinho; se a mudança foi feita direto no banco
|
||||
ou via seed, invalidar manualmente:
|
||||
```bash
|
||||
curl -X POST "https://<dominio>/api/revalidate?tag=social-feed" \
|
||||
-H "x-revalidate-secret: $REVALIDATE_SECRET"
|
||||
```
|
||||
4. **A seção inteira pode estar ausente** por outro motivo: o board só renderiza
|
||||
se houver pelo menos um card (artigo visível, YouTube ou IG). Um feed IG vazio
|
||||
com artigos e YouTube desligados esconde a seção inteira — não é bug do IG.
|
||||
|
||||
## Testar localmente (stub determinístico)
|
||||
|
||||
Os e2e rodam contra um stub local da Graph API (`tests/e2e/instagram-stub.mjs`)
|
||||
que responde `/media`, `/user`, `/refresh_access_token` e thumbnails com um
|
||||
fixture fixo, com estados alternáveis:
|
||||
|
||||
```bash
|
||||
pnpm test:e2e -- frontend.e2e.spec.ts # inclui "renders the Instagram feed with exclusions"
|
||||
```
|
||||
|
||||
O stub permite alternar estados (`ok | fail | invalid-token`) via
|
||||
`POST /__stub/state` — útil para reproduzir fail-closed e snapshot sem rede.
|
||||
Em dev manual, aponte `INSTAGRAM_API_BASE_URL` para o stub para exercitar o
|
||||
fluxo sem tocar na API real.
|
||||
|
||||
## Checklist de "configurei e não aparece"
|
||||
|
||||
- [ ] Checkbox **Feed ativo** (`enabled`) marcado.
|
||||
- [ ] Checkbox **Instagram ativo** (`instagramEnabled`) marcado.
|
||||
- [ ] **Token de acesso** preenchido e emitido pelo **Instagram Login** (Basic
|
||||
Display), NÃO pela Facebook Login (page token) — este é o erro mais comum; o
|
||||
endpoint de refresh também recusa tokens do Facebook.
|
||||
- [ ] **ID do usuário** é o numérico da conta Business/Creator (o `/me` acima
|
||||
confirma o `user_id` certo).
|
||||
- [ ] O servidor alcança `graph.instagram.com` (em produção o container precisa
|
||||
de saída para a internet; localmente, proxy/firewall bloqueando também dá
|
||||
"Não foi possível falar com a API").
|
||||
- [ ] O painel de status mostra **Sincronizado** (não "Falha" nem "Não configurado").
|
||||
- [ ] Home publicada há mais de 5 min (ou cache invalidado via `?tag=social-feed`).
|
||||
- [ ] Há ao menos um card visível no board (a seção inteira some com tudo vazio).
|
||||
- [ ] Os posts não estão marcados em **Itens excluídos** (post de grade etc.).
|
||||
|
||||
Qualquer estado de falha, o painel de status mostra o motivo em linguagem de
|
||||
produto — é por onde começar antes de culpar a configuração.
|
||||
|
||||
## Referências
|
||||
|
||||
- Runbook de token: `docs/ops/instagram-feed-token-runbook.md`
|
||||
- Código: `src/utilities/socialFeed/instagramFeed.ts` (fetch/parse/erros),
|
||||
`instagramFeedView.ts` (loader cacheado), `instagramSync.ts` (sync do hook),
|
||||
`src/globals/SocialFeedSettings.ts` (campos do admin),
|
||||
`src/components/admin/InstagramSyncStatusPanel.tsx` (painel de status)
|
||||
- Stub e2e: `tests/e2e/instagram-stub.mjs`, `tests/helpers/socialStub.ts`
|
||||
|
|
@ -91,10 +91,9 @@ history (`git revert` do PR da remoção, ou clone do main congelado do Forgejo)
|
|||
|
||||
## Rollback (do deploy)
|
||||
|
||||
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>`.
|
||||
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>`.
|
||||
|
||||
```bash
|
||||
ssh homeserver
|
||||
|
|
@ -103,7 +102,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. puxar do registry (a imagem antiga já não fica local após a limpeza):
|
||||
# 2. a imagem antiga ainda está local; se não, puxe do registry:
|
||||
docker pull localhost:5000/teqo-1313:<sha-anterior>
|
||||
docker tag localhost:5000/teqo-1313:<sha-anterior> teqo-1313:<sha-anterior>
|
||||
# 3. subir:
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
|
@ -1,167 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
# Impl: Perfis de permissão por assessor (visão × edição), configuráveis pelo coordenador/candidato
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #105 (C141)
|
||||
Intenção: docs/plans/permissao-granular-assessores.md
|
||||
Appetite restante: herdado (~2–3 dias eng) — cortes abaixo no "Rabbit holes / Não escopo"
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** coordenador/candidato define por assessor **Visão** (`Tudo | Carteira`) e **Edição** (`Tudo | Carteira | Somente leitura`) em `/campanha/assessores` (lista + detalhe + novo), com enforcement fail-closed no servidor; default = comportamento atual (Carteira · Edita carteira).
|
||||
- **O que NÃO negociar:**
|
||||
- Default Carteira·Carteira para contas novas e existentes — zero mudança até configurar.
|
||||
- Demandas ficam FORA dos eixos: Visão "Tudo" não abre demandas (regra de responsáveis = C143; aqui: carteira segue sendo o teto).
|
||||
- Apoiadores (PII) capados na carteira para todo staff abaixo de candidato/coordenador, mesmo com Visão "Tudo" — e por simetria, mesmo com Edição "Tudo".
|
||||
- Edição "Tudo" não libera escritas de coordenação (contas, `municipality.advisors`, nível de envolvimento E14, decisão de demandas escaladas).
|
||||
- Combinação incoerente (editar o que não vê) não é oferecida — e é rejeitada no servidor.
|
||||
- Leader lockdown e assimetria de votos (estimativas staff-only) intactos.
|
||||
- **O que reavaliar:** a hipótese de direção apontava "perfis na conta + enforcement nos módulos de acesso + gates nas actions + UI nos três pontos" — confirma-se, com duas correções de engenharia: (a) **`getAccessibleMunicipalityIds` NÃO pode virar o escopo de visão** (ele alimenta superfícies PII — apoiadores, telefones, feeds — que o gate capa na carteira); (b) o prologue `resolveActorScopedRead` compartilhado com demandas **não pode alargar em bloco** (vazaria demandas com Visão Tudo) — prologue irmão, profile-aware, para as coleções não sensíveis.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
F["CampaignUser.visibility/editing<br/>(default carteira/carteira, hook coerência)"]
|
||||
L["src/lib/campaignAdvisorProfile.ts<br/>(opções, rótulos, coerência — client-safe)"]
|
||||
R["Leitura: resolveProfileScopedRead + branches<br/>municípios, lideranças, updates, pledges, atividades, contatos"]
|
||||
W["Escrita: eixo Edição nos access (canUpdate*/canCreate*)<br/>+ giro check write-scoped"]
|
||||
A["actions/advisor.ts: updateAdvisorPermission<br/>+ createAdvisor com perfil"]
|
||||
U["UI: coluna Permissão + popover editor + detalhe + draft row"]
|
||||
M["Migration: add_campaign_user_advisor_permission_profile"]
|
||||
|
||||
F --> R; F --> W
|
||||
L --> U
|
||||
A --> U
|
||||
M --> F
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (perfis sobre o papel `advisor` em dois selects na conta) | B (role novo `viewer`) | C (matriz por coleção) | D (alargar `getAccessibleMunicipalityIds` por visão)
|
||||
**Recomendação:** A — dois selects (`visibility`, `editing`) em `campaignUser` + eixo de leitura profile-aware nos access de coleção + eixo de escrita por `editing`; UI em três pontos com o mesmo editor. Mantém o modelo de papéis congelado, enforcement centralizado onde o código já decide escopo (`overrideAccess: false` + `user`), e as superfícies PII intactas.
|
||||
**Rejeitadas:**
|
||||
|
||||
- B porque recria seed/modelo de papéis (rabbit hole do gate).
|
||||
- C porque explode em matriz N×M (anti-goal do gate).
|
||||
- D porque `getAccessibleMunicipalityIds` alimenta apoiadores, telefones de contas e feeds — alargá-lo abriria PII além da carteira, violando o gate; o escopo "carteira" continua sendo O escopo de operação, e a visão ampla é um eixo separado.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
**Modelo — `src/collections/CampaignUser.ts`**
|
||||
|
||||
- `visibility`: select `'carteira' | 'tudo'`, default `'carteira'`, label "Visão" (pt-BR no admin), `admin.condition` só p/ `role === 'advisor'`, `access.update: canManageCampaignUserRole` (unrestricted/admin).
|
||||
- `editing`: select `'carteira' | 'tudo' | 'somente_leitura'`, default `'carteira'`, mesma condição/access.
|
||||
- `beforeValidate` novo `enforceCoherentAdvisorProfile`: `role === 'advisor' && editing === 'tudo' && visibility !== 'tudo'` → APIError (fail-closed também via REST/admin, não só na action).
|
||||
- `preventSelfServicePrivilegedFields`: adicionar `visibility`, `editing` ao strip.
|
||||
- **Migration:** `add_campaign_user_advisor_permission_profile` (duas colunas select com default `'carteira'`; sem backfill — default cobre linhas existentes).
|
||||
|
||||
**Lib — `src/lib/campaignAdvisorProfile.ts`** (novo, client-safe)
|
||||
|
||||
- Tipos `AdvisorVisibility` / `AdvisorEditing`; `ADVISOR_VISIBILITY_OPTIONS` / `ADVISOR_EDITING_OPTIONS` (value + label + description pt-BR do draft: "Tudo — enxerga todos os municípios e lideranças.", "Somente leitura — nenhum controle de edição em lugar nenhum.", …); `advisorProfileLabel(v, e)` ("Carteira · Edita carteira", "Tudo · Somente leitura", "Tudo · Edita tudo"); `isCoherentAdvisorProfile(v, e)` (`e === 'tudo' ⇒ v === 'tudo'`). Combinar com `campaignRoles.ts` (papéis), não duplicar.
|
||||
|
||||
**Access — `src/utilities/access/`**
|
||||
|
||||
- `shared.ts`:
|
||||
- `resolveProfileScopedRead(req, scopeField, loadAccessibleIds)` — mesmo prólogo de `resolveActorScopedRead`, mas advisor com `visibility === 'tudo'` → `true`. `resolveActorScopedRead` fica como está (demandas continuam carteira — C143 substitui depois).
|
||||
- `advisorEditingAccess(currentUser)` → `'none' | 'carteira' | 'tudo'` a partir de `editing` (somente_leitura → none; tudo → tudo; senão carteira; não-assessor → none).
|
||||
- `resolveAccessibleIds`: permitir `compute` retornar `null` (= "tudo") — necessário para `getAccessibleLeadershipIds`/`getAccessibleContactIds` ampliarem sem virar `true` (que abriria PII).
|
||||
- `municipalities.ts`: `canReadMunicipality` — advisor com `visibility === 'tudo'` → `true`; `canUpdateMunicipality` — eixo: none → `false`, tudo → `true`, carteira → `{ advisors: { contains: self } }` (where atual inalterado).
|
||||
- `leaderships.ts`: `canReadLeadership` → `resolveProfileScopedRead`; `getAccessibleLeadershipIds` — advisor com visão tudo → `null`; `canCreateLeadership`/`canManageLeadership` — eixo de escrita.
|
||||
- `municipalityUpdates.ts` (+ alias `allocationDecisions.ts`): `canReadMunicipalityUpdate`/`canReadAllocationDecision` → `resolveProfileScopedRead`; `canCreateMunicipalityUpdate` — eixo.
|
||||
- `votePledges.ts`: `canReadVotePledge` → `resolveProfileScopedRead`; `canCreateVotePledge`/`canUpdateVotePledge` — eixo (tudo → `true`; none → `false`; carteira → fragmento atual).
|
||||
- `activities.ts`: `canReadActivity` — advisor com visão tudo → `true`; `canCreateActivity`/`canUpdateActivity` — eixo (carteira mantém o or-where `responsible`/`municipality`; tudo → `true`; none → `false`).
|
||||
- `demands.ts`: **inalterado na leitura** (carteira — C143 dono). Só `canUpdateCampaignDemand`/`canCreateCampaignDemand`: eixo none → `false`; tudo/carteira → comportamento atual (carteira; C143 substitui a regra depois).
|
||||
- `supporters.ts`: `canCreateSupporter`/`canManageSupporter` — eixo none → `false`; senão comportamento atual (carteira SEMPRE; PII cap nos dois eixos). `canReadSupporter` inalterado.
|
||||
- `organizations.ts` / `stateDeputies.ts`: booleans staff → eixo (none → `false` para advisor; senão comportamento atual).
|
||||
- `contacts.ts`: `getAccessibleContactIds` — ramo lideranças: advisor com visão tudo → `where: {}` (dobradinhas já são todas); ramo apoiadores: SEMPRE carteira (PII cap). `canReadContacts` segue derivando ids.
|
||||
- `calendarFeeds.ts`, `campaignUsers.ts` (phone): **inalterados** — feeds são superfície pessoal (contrato C96 re-deriva escopo na leitura) e telefone é PII; continuam carteira.
|
||||
|
||||
**Actions — `src/app/(campaign)/campanha/actions/`**
|
||||
|
||||
- `advisor.ts`: `updateAdvisorPermission` (novo) — `reloadUnrestrictedActor` + `assertTargetAdvisor` + `payload.update({ visibility, editing }, user, overrideAccess: false)` (field access `canManageCampaignUserRole` re-checa); `createAdvisorRecord` aceita `visibility`/`editing` opcionais (default carteira). `revalidateAdvisorPaths`.
|
||||
- `activity.ts` `createTourDraftActivitiesRecord`: trocar o check de escopo do giro de **leitura** para **escrita** — hoje lê municípios com access (com visão tudo o read alargaria e um assessor "Carteira" comporia giro fora da carteira). Usar `getWritableMunicipalityIds` (novo helper em `municipalities.ts`: unrestricted/tudo → `null`; none → `[]`; carteira → ids da carteira).
|
||||
- `formActions.ts` (assessores): `updateAdvisorPermissionFormAction`; `createAdvisorFormAction` lê `visibility`/`editing` do FormData.
|
||||
|
||||
**Dados/view models — `src/utilities/advisorData.ts`**
|
||||
|
||||
- `AdvisorRowViewModel` / `AdvisorDetailViewModel` ganham `visibility`/`editing`; `loadAdvisorListPageData`/`loadAdvisorDetail` incluem os campos no `select`.
|
||||
|
||||
**UI — `src/components/campaign/advisor/`** (Impeccable B — encaixe na superfície existente)
|
||||
|
||||
- `AdvisorPermissionEditor` (novo, client): dois `NativeSelect` (Visão/Edição) + nota de demandas + Cancelar/Salvar; ao escolher Edição "Tudo" com Visão "Carteira", auto-eleva Visão para "Tudo" (só combos coerentes são oferecidos); salva via formAction + `router.refresh()`.
|
||||
- `AdvisorPermissionBadge` (novo, client): badge "Carteira · Edita carteira" etc. (botão) abrindo Popover com o editor — reusado na lista e no draft row.
|
||||
- `AdvisorsTable`: coluna **`permission`** (mandatory, `headerClassName` ~`w-48`) com o badge; draft row ganha célula de permissão (estado local, default carteira/carteira) e `saveDraft` envia os campos; `DraftAdvisor` ganha `visibility`/`editing`.
|
||||
- Detalhe `[id]/page.tsx`: seção "Permissão da conta" (título + editor inline + nota de demandas/coordenação), mesma formAction.
|
||||
- Coluna participa do picker como mandatory (contrato B197+ — body sempre renderiza).
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- Sem KPI/dado novo: superfície de configuração. Rótulo "Permissão" em badge curto (coluna) e textos descritivos no editor — forma do draft do gate, reutilizada como vocabulário único (`campaignAdvisorProfile.ts`).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer — schema + leitura** (~0,5 dia): campos + hook + migration + `campaignAdvisorProfile.ts` + `resolveProfileScopedRead`/`advisorEditingAccess` + leitura dos access (municípios, lideranças, updates, pledges, atividades, contatos) + `generate:types` + int spec de leitura (visão tudo alarga; carteira não; demandas/apoiadores continuam carteira).
|
||||
2. **Escrita** (~0,5–0,75 dia): eixo `editing` nos `canUpdate*`/`canCreate*` + `getWritableMunicipalityIds` + giro + int spec (somente_leitura nega tudo; tudo permite fora da carteira exceto PII/coordenação; carteira = hoje).
|
||||
3. **Actions + dados** (~0,25 dia): `updateAdvisorPermission`, create com perfil, zod, form actions, view models/loaders.
|
||||
4. **UI** (~0,75–1 dia): editor + badge + coluna + draft row + detalhe; shape→craft→critique→polish; unit specs dos componentes/labels.
|
||||
5. **Gates**: `pnpm migrate:create` + `pnpm migrate` (local), `generate:types` (com `S3_*` dummy setados), `tsc --noEmit`, `pnpm lint`, `format:check`, `knip`, `check:cycles`, `pnpm test` (unit+int), `pnpm build`. e2e roda no CI.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Redesenho mobile da lista de assessores em cards (draft cena 2) — a tabela atual rola; apresentação por perfil é o item irmão C142. Não escopo aqui.
|
||||
- Mudar a regra de demandas (responsáveis) — C143 é o dono; aqui só o bloqueio "somente leitura" e o teto carteira.
|
||||
- Histórico/auditoria de mudanças de perfil; autoatendimento; perfil para coordinator/candidate/leader.
|
||||
- `saveToJWT` para os campos novos — desnecessário: `getFreshCampaignUser` recarrega por ID.
|
||||
- Migration de backfill — default cobre linhas existentes.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Mapa/quantis/dashboard** leem `loadMunicipalityScope` (access-driven) — alargam automaticamente com visão tudo; esperado e desejado ("mesa de trabalho completa"). Mitigação: int spec pina que o escopo do mapa acompanha o perfil.
|
||||
- **`getAccessibleLeadershipIds` alargada** abre convites para todas as lideranças com visão tudo — coerente com a visão; registrar no resumo (C142 apresenta; se a mesa discordar, item sucessor).
|
||||
- **Sollinha** usa as mesmas queries/access — segue automaticamente; conferir B185/B186 (fragmentos allowlistados por escopo já resolvido) em fase 2.
|
||||
- **Contatos**: a lista de pessoas deriva de lideranças (alarga) + apoiadores (carteira) — composição por ids, nunca `true` (evita abrir PII). Teste pina o ramo apoiadores.
|
||||
- **Combinação incoerente via API** — hook no `beforeValidate` + zod; teste pina o 400.
|
||||
- **Giro (tour)** — o check read→write é a única correção fora dos access; teste específico.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (dois eixos, default carteira, fail-closed, PII/demandas/coordenação intocados)
|
||||
- [ ] Invariantes AGENTS/engineering-standards: Local API com `user` → `overrideAccess: false`; sem re-escrita do fragmento P3-D (guarda em `codebaseConventions`); identificadores em inglês, valores/UI pt-BR
|
||||
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: `campaignAdvisorPermission.int.spec.ts` + unit dos helpers de perfil
|
||||
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
|
@ -1,322 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
# 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,19 +110,6 @@ 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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
|
@ -1,230 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -6,30 +6,5 @@
|
|||
"url": "https://design.penpot.app/mcp/stream?userToken={file:.opencode/secrets/penpot-token}",
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"cheapest-inference": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Cheapest Inference",
|
||||
"options": {
|
||||
"baseURL": "https://api.cheapestinference.com/v1"
|
||||
},
|
||||
"models": {
|
||||
"deepseek-v4-flash": {
|
||||
"name": "DeepSeek V4 Flash",
|
||||
"limit": {
|
||||
"context": 1000000,
|
||||
"output": 384000
|
||||
}
|
||||
},
|
||||
"mimo-v2.5": {
|
||||
"name": "MiMo v2.5",
|
||||
"limit": {
|
||||
"context": 1000000,
|
||||
"output": 128000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,26 +230,4 @@ 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)"
|
||||
|
|
|
|||
|
|
@ -19,8 +19,16 @@ export const PLAN_BRANCH_PREFIX = 'plans/plan-issue'
|
|||
/** Env var the interactive-terminal `worktree()` function sets to request the opencode launch. */
|
||||
export const WORKTREE_TERMINAL_ENV = 'TEQO_WORKTREE_TERMINAL'
|
||||
|
||||
/** Preset model for the opencode launch — change the preset by editing this constant. */
|
||||
export const OPENCODE_PRESET_MODEL = 'deepseek/deepseek-v4-flash'
|
||||
/**
|
||||
* Preset model for the opencode launch — change the preset by editing this
|
||||
* constant. Provider `cheapestinference` (Cheapest Inference, global config
|
||||
* in ~/.config/opencode/opencode.jsonc; auth key via `opencode auth login
|
||||
* cheapestinference`). The `max` variant is the default because opencode's
|
||||
* TUI has no `--variant` flag — the per-model variant is persisted in
|
||||
* ~/.local/state/opencode/model.json (`variant["cheapestinference/deepseek-v4-flash"]
|
||||
* = "max"`), applied automatically when the TUI opens with this model.
|
||||
*/
|
||||
export const OPENCODE_PRESET_MODEL = 'cheapestinference/deepseek-v4-flash'
|
||||
|
||||
/**
|
||||
* Skill command sent as the launch's initial message per purpose. `next`
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
* Chamado do terminal interativo (com
|
||||
* `TEQO_WORKTREE_TERMINAL=1`, que só a função shell
|
||||
* seta), imprime também a diretiva `launch
|
||||
* opencode <dir> --model deepseek/deepseek-v4-flash
|
||||
* opencode <dir> --model cheapestinference/deepseek-v4-flash
|
||||
* --auto --prompt "/work-issue --issue <N>"` ANTES
|
||||
* do `cd` — a função shell executa o cd e então a
|
||||
* linha, e o TUI do opencode abre no worktree com
|
||||
|
|
@ -775,7 +775,7 @@ if (!subcommand) {
|
|||
console.log(' opencode command, ou a função `worktree()` de .agents/shell/worktree.sh);')
|
||||
console.log(' no terminal (TEQO_WORKTREE_TERMINAL=1) imprime também a diretiva')
|
||||
console.log(' `launch opencode … --prompt "/work-issue --issue <N>"` (OPS26+OPS33:')
|
||||
console.log(' abre o TUI com deepseek/deepseek-v4-flash + auto + a Issue claimada já')
|
||||
console.log(' abre o TUI com cheapestinference/deepseek-v4-flash + auto + a Issue claimada já')
|
||||
console.log(' informada); --stay suprime cd e launch (o claim ainda acontece); --go')
|
||||
console.log(' explícito continua aceito como no-op; --no-migrate pula migrations e o')
|
||||
console.log(' seed mínimo (db:seed:minimal) nos bancos novos (OPS28: paridade com a CI)')
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { getPayload } from 'payload'
|
|||
|
||||
import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell'
|
||||
import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton'
|
||||
import { AdvisorPermissionEditor } from '@/components/campaign/advisor/AdvisorPermissionEditor'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
|
|
@ -19,7 +18,6 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
|||
import {
|
||||
sendAdvisorPasswordResetFormAction,
|
||||
updateAdvisorContactFormAction,
|
||||
updateAdvisorPermissionFormAction,
|
||||
updateAdvisorProfileFormAction,
|
||||
} from '../formActions'
|
||||
|
||||
|
|
@ -91,27 +89,6 @@ export default async function AdvisorDetailPage({ params }: AdvisorDetailPagePro
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="advisor-permission-title"
|
||||
className="flex flex-col gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 id="advisor-permission-title" className="text-base font-medium">
|
||||
Permissão da conta
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
O que este assessor vê e o que pode editar nas telas do /campanha.
|
||||
</p>
|
||||
<AdvisorPermissionEditor
|
||||
advisorId={advisor.id}
|
||||
visibility={advisor.visibility}
|
||||
editing={advisor.editing}
|
||||
formAction={updateAdvisorPermissionFormAction}
|
||||
showHeading={false}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="advisor-municipalities-title"
|
||||
className="flex flex-col gap-3 rounded-xl border p-4"
|
||||
|
|
|
|||
|
|
@ -5,13 +5,10 @@ import {
|
|||
sendAdvisorPasswordReset,
|
||||
setAdvisorMunicipalitiesBatch,
|
||||
updateAdvisorContactFicha,
|
||||
updateAdvisorPermission,
|
||||
updateAdvisorProfile,
|
||||
} from '@/app/(campaign)/campanha/actions/advisor'
|
||||
import { isAdvisorEditingValue, isAdvisorVisibilityValue } from '@/lib/campaignAdvisorProfile'
|
||||
import {
|
||||
nullableFormText,
|
||||
optionalFormText,
|
||||
repeatedPhoneFormValues,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormBoolean,
|
||||
|
|
@ -119,21 +116,10 @@ export const createAdvisorFormAction = async (
|
|||
): Promise<CampaignFormActionState & { advisorId?: number }> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const rawVisibility = optionalFormText(formData, 'visibility')
|
||||
const rawEditing = optionalFormText(formData, 'editing')
|
||||
if (
|
||||
(rawVisibility !== undefined && !isAdvisorVisibilityValue(rawVisibility)) ||
|
||||
(rawEditing !== undefined && !isAdvisorEditingValue(rawEditing))
|
||||
) {
|
||||
throw new Error(INVALID_FIELD_MESSAGE)
|
||||
}
|
||||
|
||||
const created = await createAdvisor({
|
||||
name: requiredFormText(formData, 'name'),
|
||||
email: requiredFormText(formData, 'email'),
|
||||
phone: nullableFormText(formData, 'phone') ?? undefined,
|
||||
visibility: rawVisibility,
|
||||
editing: rawEditing,
|
||||
})
|
||||
|
||||
const municipalityIds = repeatedRelationshipFormValues(formData, 'municipalityIds')
|
||||
|
|
@ -150,30 +136,3 @@ export const createAdvisorFormAction = async (
|
|||
safeMessages: advisorSafeMessages,
|
||||
genericMessage: 'Não foi possível criar o assessor. Verifique os dados e tente novamente.',
|
||||
})
|
||||
|
||||
/**
|
||||
* C141 — the advisor permission profile (Visão × Edição), from the list
|
||||
* popover and the detail section.
|
||||
*/
|
||||
export const updateAdvisorPermissionFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const rawVisibility = requiredFormText(formData, 'visibility')
|
||||
const rawEditing = requiredFormText(formData, 'editing')
|
||||
if (!isAdvisorVisibilityValue(rawVisibility) || !isAdvisorEditingValue(rawEditing)) {
|
||||
throw new Error(INVALID_FIELD_MESSAGE)
|
||||
}
|
||||
|
||||
await updateAdvisorPermission({
|
||||
id: requiredRelationshipFormValue(formData, 'advisorId'),
|
||||
visibility: rawVisibility,
|
||||
editing: rawEditing,
|
||||
})
|
||||
return { message: 'Permissão atualizada.' }
|
||||
},
|
||||
safeMessages: advisorSafeMessages,
|
||||
genericMessage: 'Não foi possível atualizar a permissão.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import {
|
|||
createAdvisorFormAction,
|
||||
sendAdvisorPasswordResetFormAction,
|
||||
setAdvisorMunicipalitiesFormAction,
|
||||
updateAdvisorPermissionFormAction,
|
||||
updateAdvisorProfileFormAction,
|
||||
} from './formActions'
|
||||
|
||||
|
|
@ -83,7 +82,6 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps)
|
|||
municipalitiesAction={setAdvisorMunicipalitiesFormAction}
|
||||
createAction={createAdvisorFormAction}
|
||||
passwordResetAction={sendAdvisorPasswordResetFormAction}
|
||||
updatePermissionAction={updateAdvisorPermissionFormAction}
|
||||
autoCreateDraft={autoCreateDraft}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type { Payload } from 'payload'
|
|||
|
||||
import {
|
||||
ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE,
|
||||
ACTIVITY_OUT_OF_SCOPE_MESSAGE,
|
||||
ACTIVITY_RESCHEDULE_FAILED_MESSAGE,
|
||||
ACTIVITY_RESULT_REQUIRED_MESSAGE,
|
||||
ACTIVITY_RESULT_STAFF_MESSAGE,
|
||||
|
|
@ -32,11 +31,7 @@ import {
|
|||
import { mapActivityOverlayError } from '@/utilities/activityOverlayErrors'
|
||||
import { loadActivityAgendaEventsData } from '@/utilities/activityPageData'
|
||||
import { activityFormSelect, toActivityFormViewModel } from '@/utilities/activityViewModels'
|
||||
import {
|
||||
canCampaignUserRescheduleActivity,
|
||||
getWritableMunicipalityIds,
|
||||
isCampaignStaff,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { canCampaignUserRescheduleActivity, isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
|
||||
import { hookFilledCreateData } from '@/utilities/hookFilledData'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
|
|
@ -119,17 +114,6 @@ export const createActivityRecord = async (
|
|||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadCampaignActor(payload, actor, req)
|
||||
|
||||
// C141 — `canCreateActivity` is a plain staff boolean (Payload cannot
|
||||
// express a per-município constraint on create), so the individual
|
||||
// create must check the municipality against the WRITE scope itself —
|
||||
// with Visão "Tudo" + Edição "Carteira" the read widens while the
|
||||
// carteira stays the edit boundary (same rule as the giro batch).
|
||||
const writableIDs = await getWritableMunicipalityIds(payload, currentActor, req)
|
||||
if (writableIDs !== null && !writableIDs.includes(raw.municipality)) {
|
||||
throw new Error(ACTIVITY_OUT_OF_SCOPE_MESSAGE)
|
||||
}
|
||||
|
||||
const activity = await payload.create({
|
||||
collection: 'activity',
|
||||
data: hookFilledCreateData<'activity'>(data),
|
||||
|
|
@ -206,15 +190,6 @@ export const createTourDraftActivitiesRecord = async (
|
|||
throw new Error(TOUR_OUT_OF_SCOPE_MESSAGE)
|
||||
}
|
||||
|
||||
// C141 — the read above proves the stops EXIST, but the stop set must sit
|
||||
// inside the WRITE scope: with Visão "Tudo" + Edição "Carteira" the read
|
||||
// widens while the carteira stays the edit boundary. `null` = whole
|
||||
// catalog (unrestricted or editing=tudo); `[]` = no writes at all.
|
||||
const writableIDs = await getWritableMunicipalityIds(payload, currentActor, req)
|
||||
if (writableIDs !== null && !municipalityIDs.every((id) => writableIDs.includes(id))) {
|
||||
throw new Error(TOUR_OUT_OF_SCOPE_MESSAGE)
|
||||
}
|
||||
|
||||
// The names come from the same read that authorized the stops, so a stop's
|
||||
// title always names the município it was actually created in, and the
|
||||
// giro reads as one set in the Atividades list.
|
||||
|
|
|
|||
|
|
@ -16,13 +16,11 @@ import {
|
|||
advisorCreateSchema,
|
||||
advisorMunicipalitiesBatchSchema,
|
||||
advisorPasswordResetSchema,
|
||||
advisorPermissionUpdateSchema,
|
||||
advisorProfileUpdateSchema,
|
||||
isPlanilhaPlaceholderEmail,
|
||||
type AdvisorCreateInput,
|
||||
type AdvisorMunicipalitiesBatchInput,
|
||||
type AdvisorPasswordResetInput,
|
||||
type AdvisorPermissionUpdateInput,
|
||||
type AdvisorProfileUpdateInput,
|
||||
} from '@/lib/schemas/advisor'
|
||||
import { contactPhonesSchema } from '@/lib/schemas/contact'
|
||||
|
|
@ -107,8 +105,6 @@ export const createAdvisorRecord = async (
|
|||
email: data.email,
|
||||
role: 'advisor',
|
||||
password: randomBytes(24).toString('base64url'),
|
||||
visibility: data.visibility,
|
||||
editing: data.editing,
|
||||
...(data.phone !== undefined ? { phone: data.phone } : {}),
|
||||
}),
|
||||
depth: 0,
|
||||
|
|
@ -270,38 +266,6 @@ export const setAdvisorMunicipalitiesBatch = async (input: AdvisorMunicipalities
|
|||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* C141 — set the advisor's permission profile (Visão × Edição). The coherence
|
||||
* rule (Edição "Tudo" ⇒ Visão "Tudo") is enforced by the zod schema AND the
|
||||
* collection's `enforceCoherentAdvisorProfile` beforeValidate hook; the field
|
||||
* update access (`canManageCampaignUserRole`) re-checks the unrestricted role.
|
||||
*/
|
||||
export const updateAdvisorPermissionRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: AdvisorPermissionUpdateInput,
|
||||
) => {
|
||||
const { id, visibility, editing } = advisorPermissionUpdateSchema.parse(input)
|
||||
const currentActor = await reloadUnrestrictedActor(payload, actor, ADVISOR_UNRESTRICTED_MESSAGE)
|
||||
await assertTargetAdvisor(payload, id, currentActor.id)
|
||||
|
||||
return payload.update({
|
||||
collection: 'campaignUser',
|
||||
id,
|
||||
data: { visibility, editing },
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
}
|
||||
|
||||
export const updateAdvisorPermission = async (input: AdvisorPermissionUpdateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const updated = await updateAdvisorPermissionRecord(payload, actor, input)
|
||||
revalidateAdvisorPaths(updated.id)
|
||||
return updated
|
||||
}
|
||||
|
||||
export const sendAdvisorPasswordResetRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
|
|
|
|||
|
|
@ -15,13 +15,13 @@ import {
|
|||
type ContactFullUpdateInput,
|
||||
} from '@/lib/schemas/contact'
|
||||
import type { CampaignUser, Contact } from '@/payload-types'
|
||||
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
|
||||
import { assertContactNameAvailable } from '@/utilities/contacts/contactNameInvariant'
|
||||
import {
|
||||
withPayloadTransaction,
|
||||
type PayloadTransactionRequest,
|
||||
} from '@/utilities/payloadTransaction'
|
||||
import { assertPersonContactWritable } from '@/utilities/person/personContactWriteScope'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
/**
|
||||
|
|
@ -33,25 +33,25 @@ import type { Payload } from 'payload'
|
|||
* the gate, never standalone.
|
||||
*/
|
||||
|
||||
/**
|
||||
* C141 — the contacts-page cell write gate: the ficha must sit in the actor's
|
||||
* WRITE scope (see `assertPersonContactWritable`). Before C141 this asserted
|
||||
* "you edit what you see" via the READ access, which Visão "Tudo" would have
|
||||
* widened into a statewide PII write for a carteira-editing advisor.
|
||||
*/
|
||||
const assertContactRowEditable = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
contactID: number,
|
||||
req: PayloadTransactionRequest,
|
||||
): Promise<void> => {
|
||||
await assertPersonContactWritable({
|
||||
payload,
|
||||
actor,
|
||||
contactID,
|
||||
if (isCampaignUnrestricted(actor)) return
|
||||
|
||||
const visible = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { id: { equals: contactID } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
errorMessage: CONTACT_CELL_NOT_IN_SCOPE_MESSAGE,
|
||||
})
|
||||
if (visible.docs.length === 0) throw new Error(CONTACT_CELL_NOT_IN_SCOPE_MESSAGE)
|
||||
}
|
||||
|
||||
const createContactRecord = async (
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import {
|
|||
type MunicipalityLeadershipCreateInput,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import type { CampaignUser, Contact, Leadership } from '@/payload-types'
|
||||
import { getWritableMunicipalityIds } from '@/utilities/campaignAccess'
|
||||
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
|
||||
import {
|
||||
getCampaignActionContext,
|
||||
reloadStaffActor,
|
||||
|
|
@ -54,14 +54,7 @@ const getFreshStaffActor = (
|
|||
req?: PayloadTransactionRequest,
|
||||
): Promise<CampaignUser> => reloadStaffActor(payload, actor, LEADERSHIP_STAFF_MESSAGE, req)
|
||||
|
||||
/**
|
||||
* C141 — advisors may only link leaderships to municipalities in their WRITE
|
||||
* scope (the Edição axis): `tudo` → any municipality, `carteira` → the
|
||||
* administered ones, `somente_leitura` → none. The collection access
|
||||
* (`canCreateLeadership`/`canManageLeadership`) already resolves the same
|
||||
* axis; this assert keeps the server action's per-municipality check honest
|
||||
* (Payload cannot express a per-município constraint on create).
|
||||
*/
|
||||
/** Advisors may only link leaderships to municipalities they administer. */
|
||||
const assertMunicipalitiesWithinScope = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
|
|
@ -70,10 +63,8 @@ const assertMunicipalitiesWithinScope = async (
|
|||
) => {
|
||||
if (actor.role !== 'advisor') return
|
||||
|
||||
const writable = await getWritableMunicipalityIds(payload, actor, req)
|
||||
if (writable === null) return
|
||||
|
||||
const outside = municipalityIDs.filter((id) => !writable.includes(id))
|
||||
const administered = new Set(await getAdvisorMunicipalityIds(payload, actor.id, req))
|
||||
const outside = municipalityIDs.filter((id) => !administered.has(id))
|
||||
if (outside.length > 0) {
|
||||
throw new Error(LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import {
|
|||
} from '@/lib/schemas/personDelete'
|
||||
import { nextStateDeputyAdvisorIdsAfterMembership } from '@/lib/stateDeputyAdvisorMembership'
|
||||
import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types'
|
||||
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
|
||||
import { getAdvisorMunicipalityIds, isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import {
|
||||
getCampaignActionContext,
|
||||
reloadStaffActor,
|
||||
|
|
@ -63,7 +63,6 @@ import {
|
|||
deletePersonRecord,
|
||||
loadPersonDeleteManifest,
|
||||
} from '@/utilities/people/personDelete'
|
||||
import { assertPersonContactWritable } from '@/utilities/person/personContactWriteScope'
|
||||
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
||||
import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant'
|
||||
import { stateDeputyPolicy } from '@/utilities/stateDeputyConflict'
|
||||
|
|
@ -121,11 +120,11 @@ export const deletePersonAction = async (input: unknown) => {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* C116 + C141 — the cell-edit scope rule for a person's ficha fields: the
|
||||
* person must sit in the actor's WRITE scope (see `assertPersonContactWritable`).
|
||||
* Unrestricted actors always pass; `somente_leitura` advisors never write a
|
||||
* ficha; carteira advisors edit persons whose leaderships/supporters are in
|
||||
* writable municipalities (dobradinhas keep their staff-wide status quo).
|
||||
* C116 — the cell-edit scope rule for a person's ficha fields: unrestricted
|
||||
* actors always pass; an advisor passes only when at least ONE entity of the
|
||||
* person (leadership or dobradinha) is readable with his own access — the "you
|
||||
* edit what you see" rule, mirroring the row's visibility (a staff-only person
|
||||
* is not entity-manageable and therefore not editable by an advisor).
|
||||
*/
|
||||
const assertPersonContactEditable = async (
|
||||
payload: Payload,
|
||||
|
|
@ -133,13 +132,34 @@ const assertPersonContactEditable = async (
|
|||
contactID: number,
|
||||
req: { transactionID: number | string },
|
||||
): Promise<void> => {
|
||||
await assertPersonContactWritable({
|
||||
payload,
|
||||
actor,
|
||||
contactID,
|
||||
req,
|
||||
errorMessage: PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
|
||||
})
|
||||
if (isCampaignUnrestricted(actor)) return
|
||||
|
||||
const [leaderships, deputies] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'leadership',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
}),
|
||||
])
|
||||
|
||||
if (leaderships.docs.length === 0 && deputies.docs.length === 0) {
|
||||
throw new Error(PERSON_CELL_NOT_IN_SCOPE_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
export const updatePersonContactRecord = async (
|
||||
|
|
|
|||
|
|
@ -5,11 +5,8 @@ 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 = ({ pixelId }: { pixelId?: string }) => (
|
||||
export const CampaignNewsletterSection = () => (
|
||||
<section
|
||||
id="novidades"
|
||||
aria-labelledby="novidades-title"
|
||||
|
|
@ -33,7 +30,7 @@ export const CampaignNewsletterSection = ({ pixelId }: { pixelId?: string }) =>
|
|||
</p>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<CampaignNewsletterCapture pixelId={pixelId} />
|
||||
<CampaignNewsletterCapture />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
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-*`.
|
||||
|
|
@ -18,7 +12,6 @@ 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,7 +5,6 @@ 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'
|
||||
|
||||
|
|
@ -111,9 +110,6 @@ 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">
|
||||
|
|
@ -235,7 +231,7 @@ export default async function HomePage() {
|
|||
</section>
|
||||
|
||||
<CampaignStorySection />
|
||||
<CampaignNewsletterSection pixelId={pixelId ?? undefined} />
|
||||
<CampaignNewsletterSection />
|
||||
</main>
|
||||
|
||||
<CampaignFooter />
|
||||
|
|
|
|||
|
|
@ -1,15 +1,7 @@
|
|||
import {
|
||||
ADVISOR_EDITING_OPTIONS,
|
||||
ADVISOR_VISIBILITY_OPTIONS,
|
||||
isCoherentAdvisorProfile,
|
||||
} from '@/lib/campaignAdvisorProfile'
|
||||
import { CAMPAIGN_SESSION_TTL_LONG } from '@/lib/campaignSessionTtl'
|
||||
import { normalizeBrazilianPhone } from '@/lib/phone'
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import {
|
||||
ADVISOR_PROFILE_COHERENCE_MESSAGE,
|
||||
isPlanilhaPlaceholderEmail,
|
||||
} from '@/lib/schemas/advisor'
|
||||
import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
|
||||
import type { CampaignUser as CampaignUserDocument } from '@/payload-types'
|
||||
import {
|
||||
canCreateCampaignUserPhone,
|
||||
|
|
@ -30,7 +22,6 @@ import {
|
|||
type CollectionAfterReadHook,
|
||||
type CollectionBeforeChangeHook,
|
||||
type CollectionBeforeDeleteHook,
|
||||
type CollectionBeforeValidateHook,
|
||||
type CollectionConfig,
|
||||
type Payload,
|
||||
type PayloadRequest,
|
||||
|
|
@ -126,45 +117,12 @@ const preventSelfServicePrivilegedFields: CollectionBeforeChangeHook<
|
|||
|
||||
if (String(req.user.id) !== String(originalDoc.id)) return data
|
||||
|
||||
for (const field of [
|
||||
'role',
|
||||
'name',
|
||||
'email',
|
||||
'username',
|
||||
'phone',
|
||||
'contact',
|
||||
'visibility',
|
||||
'editing',
|
||||
] as const) {
|
||||
for (const field of ['role', 'name', 'email', 'username', 'phone', 'contact'] as const) {
|
||||
if (field in data) delete data[field]
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* C141 — the advisor permission profile must stay coherent: Edição "Tudo"
|
||||
* (edits everything visible) only makes sense with Visão "Tudo". Fail-closed on
|
||||
* the API itself (REST/admin included), not just in the campaign form actions.
|
||||
* Non-advisor roles never consult the profile, so the rule only guards advisors.
|
||||
*/
|
||||
const enforceCoherentAdvisorProfile: CollectionBeforeValidateHook<CampaignUserDocument> = async ({
|
||||
data,
|
||||
originalDoc,
|
||||
}) => {
|
||||
if (!data) return data
|
||||
|
||||
const role = data.role ?? originalDoc?.role
|
||||
if (role !== 'advisor') return data
|
||||
|
||||
const visibility = data.visibility ?? originalDoc?.visibility ?? 'carteira'
|
||||
const editing = data.editing ?? originalDoc?.editing ?? 'carteira'
|
||||
if (!isCoherentAdvisorProfile(visibility, editing)) {
|
||||
throw new APIError(ADVISOR_PROFILE_COHERENCE_MESSAGE, 400)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
/**
|
||||
* Resolves the `Contact` ficha for an account that has none yet: by phone
|
||||
* (find-or-create, same dedupe policy as the supporter flows) or a fresh
|
||||
|
|
@ -425,7 +383,6 @@ export const CampaignUser: CollectionConfig = {
|
|||
delete: canManageCampaignUsers,
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [enforceCoherentAdvisorProfile],
|
||||
beforeChange: [
|
||||
preventAssignedAdvisorDowngrade,
|
||||
preventSelfServicePrivilegedFields,
|
||||
|
|
@ -484,36 +441,6 @@ export const CampaignUser: CollectionConfig = {
|
|||
update: canManageCampaignUserRole,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'visibility',
|
||||
type: 'select',
|
||||
label: 'Visão',
|
||||
required: true,
|
||||
defaultValue: 'carteira',
|
||||
admin: {
|
||||
description: 'O que o assessor enxerga no /campanha.',
|
||||
condition: (data) => data.role === 'advisor',
|
||||
},
|
||||
options: ADVISOR_VISIBILITY_OPTIONS.map(({ value, label }) => ({ value, label })),
|
||||
access: {
|
||||
update: canManageCampaignUserRole,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'editing',
|
||||
type: 'select',
|
||||
label: 'Edição',
|
||||
required: true,
|
||||
defaultValue: 'carteira',
|
||||
admin: {
|
||||
description: 'O que o assessor pode editar no /campanha.',
|
||||
condition: (data) => data.role === 'advisor',
|
||||
},
|
||||
options: ADVISOR_EDITING_OPTIONS.map(({ value, label }) => ({ value, label })),
|
||||
access: {
|
||||
update: canManageCampaignUserRole,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
FACEBOOK_PIXEL_ID_DESCRIPTION,
|
||||
normalizeFacebookPixelId,
|
||||
validateFacebookPixelId,
|
||||
} from '@/lib/facebookPixel'
|
||||
import { normalizeFacebookPixelId } from '@/lib/facebookPixel'
|
||||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import { revalidateDocumentById } from '@/utilities/documents'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
|
@ -120,9 +116,16 @@ export const Petition: CollectionConfig<typeof slug> = {
|
|||
type: 'text',
|
||||
label: 'ID do Pixel do Meta (Facebook)',
|
||||
admin: {
|
||||
description: FACEBOOK_PIXEL_ID_DESCRIPTION,
|
||||
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
|
||||
},
|
||||
validate: validateFacebookPixelId,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,18 +10,13 @@ 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
|
||||
}
|
||||
|
||||
|
|
@ -29,9 +24,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 fires `Lead`).
|
||||
* in-place confirmation (the seam where S10 will fire `Lead`).
|
||||
*/
|
||||
const CampaignNewsletterForm = ({ pixelId, onSubmit }: CampaignNewsletterFormProps) => {
|
||||
const CampaignNewsletterForm = ({ onSubmit }: CampaignNewsletterFormProps) => {
|
||||
const methods = useForm<CampaignNewsletterInput>({
|
||||
resolver: zodResolver(campaignNewsletterSchema),
|
||||
defaultValues: {
|
||||
|
|
@ -51,18 +46,6 @@ const CampaignNewsletterForm = ({ pixelId, onSubmit }: CampaignNewsletterFormPro
|
|||
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) {
|
||||
|
|
@ -187,12 +170,12 @@ const CampaignNewsletterSuccess = () => (
|
|||
)
|
||||
|
||||
/** S9 — wrapper owning the form ↔ success swap on the home section. */
|
||||
export const CampaignNewsletterCapture = ({ pixelId }: { pixelId?: string }) => {
|
||||
export const CampaignNewsletterCapture = () => {
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
return submitted ? (
|
||||
<CampaignNewsletterSuccess />
|
||||
) : (
|
||||
<CampaignNewsletterForm pixelId={pixelId} onSubmit={() => setSubmitted(true)} />
|
||||
<CampaignNewsletterForm onSubmit={() => setSubmitted(true)} />
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import { AdvisorPermissionEditor } from '@/components/campaign/advisor/AdvisorPermissionEditor'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import {
|
||||
advisorProfileLabel,
|
||||
type AdvisorEditing,
|
||||
type AdvisorVisibility,
|
||||
} from '@/lib/campaignAdvisorProfile'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
type AdvisorPermissionBadgeProps = {
|
||||
visibility: AdvisorVisibility
|
||||
editing: AdvisorEditing
|
||||
/** Present = saved on the server; absent = new-advisor draft row. */
|
||||
advisorId?: number
|
||||
formAction?: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
onDraftChange?: (visibility: AdvisorVisibility, editing: AdvisorEditing) => void
|
||||
/** Names the account for the badge's accessible label. */
|
||||
advisorName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* C141 — the "Permissão" cell: a compact badge that opens the Visão × Edição
|
||||
* editor in a popover. Reused in the advisors list and the new-advisor draft
|
||||
* row; the detail page embeds the editor directly in its own section.
|
||||
*/
|
||||
export const AdvisorPermissionBadge = ({
|
||||
visibility,
|
||||
editing,
|
||||
advisorId,
|
||||
formAction,
|
||||
onDraftChange,
|
||||
advisorName = 'conta',
|
||||
}: AdvisorPermissionBadgeProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded bg-muted px-2 py-0.5 text-xs font-bold text-foreground underline-offset-4 hover:bg-accent hover:underline"
|
||||
aria-label={`Permissão de ${advisorName}`}
|
||||
>
|
||||
{advisorProfileLabel(visibility, editing)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="start">
|
||||
<AdvisorPermissionEditor
|
||||
visibility={visibility}
|
||||
editing={editing}
|
||||
advisorId={advisorId}
|
||||
formAction={formAction}
|
||||
onDraftChange={onDraftChange}
|
||||
onSaved={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import {
|
||||
ADVISOR_EDITING_OPTIONS,
|
||||
ADVISOR_VISIBILITY_OPTIONS,
|
||||
type AdvisorEditing,
|
||||
type AdvisorVisibility,
|
||||
} from '@/lib/campaignAdvisorProfile'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
type AdvisorPermissionEditorProps = {
|
||||
visibility: AdvisorVisibility
|
||||
editing: AdvisorEditing
|
||||
/**
|
||||
* Present = the profile is saved on the server through `formAction`;
|
||||
* absent = draft mode (new-advisor row), where the values only update local
|
||||
* state via `onDraftChange`.
|
||||
*/
|
||||
advisorId?: number
|
||||
formAction?: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
onDraftChange?: (visibility: AdvisorVisibility, editing: AdvisorEditing) => void
|
||||
/** Fired after a successful save (or draft change) — closes the popover. */
|
||||
onSaved?: () => void
|
||||
/** When provided, renders a "Cancelar" button (popover footer). */
|
||||
onCancel?: () => void
|
||||
/** The popover renders its own heading; the detail section has its own h2. */
|
||||
showHeading?: boolean
|
||||
}
|
||||
|
||||
const EDITING_NOTE =
|
||||
'Demandas seguem regra própria: só quem é responsável pela demanda (e candidato/coordenador) a vê — mesmo com visão "Tudo". Decisões de coordenação (contas, carteiras, nível de envolvimento, demandas escaladas) continuam restritas a coordenador e candidato.'
|
||||
|
||||
/**
|
||||
* C141 — the Visão × Edição editor, shared by the advisors list popover, the
|
||||
* advisor detail section and the new-advisor draft row. Only coherent
|
||||
* combinations are offered: picking Edição "Tudo" raises Visão to "Tudo", and
|
||||
* lowering Visão to "Carteira" drops Edição to "Carteira".
|
||||
*/
|
||||
export const AdvisorPermissionEditor = ({
|
||||
visibility: initialVisibility,
|
||||
editing: initialEditing,
|
||||
advisorId,
|
||||
formAction,
|
||||
onDraftChange,
|
||||
onSaved,
|
||||
onCancel,
|
||||
showHeading = true,
|
||||
}: AdvisorPermissionEditorProps) => {
|
||||
const [visibility, setVisibility] = useState(initialVisibility)
|
||||
const [editing, setEditing] = useState(initialEditing)
|
||||
const [isSaving, startSave] = useTransition()
|
||||
|
||||
const updateEditing = (next: AdvisorEditing) => {
|
||||
if (next === 'tudo') setVisibility('tudo')
|
||||
setEditing(next)
|
||||
}
|
||||
|
||||
const updateVisibility = (next: AdvisorVisibility) => {
|
||||
setVisibility(next)
|
||||
if (next === 'carteira' && editing === 'tudo') setEditing('carteira')
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
if (advisorId === undefined || formAction === undefined) {
|
||||
onDraftChange?.(visibility, editing)
|
||||
onSaved?.()
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set('advisorId', String(advisorId))
|
||||
formData.set('visibility', visibility)
|
||||
formData.set('editing', editing)
|
||||
|
||||
startSave(async () => {
|
||||
const result = await formAction({}, formData)
|
||||
if (result.status === 'success') {
|
||||
toast.success(result.message ?? 'Permissão atualizada.')
|
||||
onSaved?.()
|
||||
} else {
|
||||
toast.error(result.message ?? 'Não foi possível atualizar a permissão.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const selectedVisibility = ADVISOR_VISIBILITY_OPTIONS.find(
|
||||
(option) => option.value === visibility,
|
||||
)
|
||||
const selectedEditing = ADVISOR_EDITING_OPTIONS.find((option) => option.value === editing)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{showHeading ? (
|
||||
<div>
|
||||
<p className="text-sm font-bold">Permissão da conta</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Vale para todas as telas do /campanha.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="advisor-permission-visibility" className="text-xs font-bold">
|
||||
Visão
|
||||
</label>
|
||||
<NativeSelect
|
||||
id="advisor-permission-visibility"
|
||||
value={visibility}
|
||||
onChange={(event) => updateVisibility(event.currentTarget.value as AdvisorVisibility)}
|
||||
className="w-full"
|
||||
>
|
||||
{ADVISOR_VISIBILITY_OPTIONS.map((option) => (
|
||||
<NativeSelectOption key={option.value} value={option.value}>
|
||||
{option.label} — {option.description}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
{selectedVisibility?.description}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="advisor-permission-editing" className="text-xs font-bold">
|
||||
Edição
|
||||
</label>
|
||||
<NativeSelect
|
||||
id="advisor-permission-editing"
|
||||
value={editing}
|
||||
onChange={(event) => updateEditing(event.currentTarget.value as AdvisorEditing)}
|
||||
className="w-full"
|
||||
>
|
||||
{ADVISOR_EDITING_OPTIONS.map((option) => (
|
||||
<NativeSelectOption key={option.value} value={option.value}>
|
||||
{option.label} — {option.description}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
{selectedEditing?.description}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted px-3 py-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||
{EDITING_NOTE}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{onCancel ? (
|
||||
<Button type="button" variant="outline" className="min-h-9" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" className="min-h-9" onClick={save} disabled={isSaving}>
|
||||
{isSaving ? <Spinner className="size-4" aria-hidden="true" /> : null}
|
||||
Salvar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -16,7 +16,6 @@ import { toast } from 'sonner'
|
|||
|
||||
import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell'
|
||||
import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton'
|
||||
import { AdvisorPermissionBadge } from '@/components/campaign/advisor/AdvisorPermissionBadge'
|
||||
import {
|
||||
CampaignCopyableCell,
|
||||
campaignReadCellClassName,
|
||||
|
|
@ -35,7 +34,6 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/Table'
|
||||
import type { AdvisorEditing, AdvisorVisibility } from '@/lib/campaignAdvisorProfile'
|
||||
import {
|
||||
resolveVisibleColumns,
|
||||
toCampaignColumnPickerColumns,
|
||||
|
|
@ -58,8 +56,6 @@ type DraftAdvisor = {
|
|||
email: string
|
||||
phone: string
|
||||
municipalityIds: number[]
|
||||
visibility: AdvisorVisibility
|
||||
editing: AdvisorEditing
|
||||
}
|
||||
|
||||
// B197+ — single source for the `<th>`s and the picker menu: a column added
|
||||
|
|
@ -82,7 +78,6 @@ const advisorTableColumns: readonly AdvisorTableColumn[] = [
|
|||
{ id: 'email', label: 'E-mail', headerClassName: 'w-[24%]' },
|
||||
{ id: 'phone', label: 'Celular', mandatory: true, headerClassName: 'w-[14%]' },
|
||||
{ id: 'municipalities', label: 'Municípios', mandatory: true },
|
||||
{ id: 'permission', label: 'Permissão', mandatory: true, headerClassName: 'w-48' },
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Ações',
|
||||
|
|
@ -116,10 +111,6 @@ type AdvisorsTableProps = {
|
|||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
updatePermissionAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
autoCreateDraft?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -128,8 +119,6 @@ const emptyDraft = (): DraftAdvisor => ({
|
|||
email: '',
|
||||
phone: '',
|
||||
municipalityIds: [],
|
||||
visibility: 'carteira',
|
||||
editing: 'carteira',
|
||||
})
|
||||
|
||||
const displayEmail = (email: string | null): string | null => {
|
||||
|
|
@ -146,7 +135,6 @@ export const AdvisorsTable = ({
|
|||
municipalitiesAction,
|
||||
createAction,
|
||||
passwordResetAction,
|
||||
updatePermissionAction,
|
||||
autoCreateDraft = false,
|
||||
}: AdvisorsTableProps) => {
|
||||
const router = useRouter()
|
||||
|
|
@ -204,8 +192,6 @@ export const AdvisorsTable = ({
|
|||
const formData = new FormData()
|
||||
formData.set('name', draft.name.trim())
|
||||
formData.set('email', draft.email.trim())
|
||||
formData.set('visibility', draft.visibility)
|
||||
formData.set('editing', draft.editing)
|
||||
if (draft.phone.trim()) formData.set('phone', draft.phone.trim())
|
||||
for (const municipalityId of draft.municipalityIds) {
|
||||
formData.append('municipalityIds', String(municipalityId))
|
||||
|
|
@ -347,18 +333,6 @@ export const AdvisorsTable = ({
|
|||
updateErrorMessage="Não foi possível atualizar a carteira."
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdvisorPermissionBadge
|
||||
visibility={draft.visibility}
|
||||
editing={draft.editing}
|
||||
advisorName={draft.name.trim() || 'o novo assessor'}
|
||||
onDraftChange={(visibility, editing) =>
|
||||
setDraft((current) =>
|
||||
current ? { ...current, visibility, editing } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
|
|
@ -469,15 +443,6 @@ export const AdvisorsTable = ({
|
|||
updateErrorMessage="Não foi possível atualizar a carteira."
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdvisorPermissionBadge
|
||||
advisorId={row.id}
|
||||
visibility={row.visibility}
|
||||
editing={row.editing}
|
||||
advisorName={row.name}
|
||||
formAction={updatePermissionAction}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{whatsAppHref ? (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
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'
|
||||
|
|
@ -24,14 +19,6 @@ 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: [
|
||||
|
|
@ -95,22 +82,6 @@ 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,88 +0,0 @@
|
|||
/**
|
||||
* C141 — the advisor permission profile (Visão × Edição), configured by
|
||||
* coordinator/candidate on `/campanha/assessores`. Client-safe: options,
|
||||
* labels and the coherence rule ship to the permission editor UI; the
|
||||
* enforcement reads the same field values in `src/utilities/access/*`.
|
||||
*
|
||||
* Default profile (new and existing accounts): Carteira · Edita carteira —
|
||||
* exactly today's behavior, until someone configures otherwise.
|
||||
*/
|
||||
export const ADVISOR_VISIBILITY_VALUES = ['carteira', 'tudo'] as const
|
||||
export type AdvisorVisibility = (typeof ADVISOR_VISIBILITY_VALUES)[number]
|
||||
|
||||
export const ADVISOR_EDITING_VALUES = ['carteira', 'tudo', 'somente_leitura'] as const
|
||||
export type AdvisorEditing = (typeof ADVISOR_EDITING_VALUES)[number]
|
||||
|
||||
export type AdvisorVisibilityOption = {
|
||||
value: AdvisorVisibility
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type AdvisorEditingOption = {
|
||||
value: AdvisorEditing
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const ADVISOR_VISIBILITY_OPTIONS: readonly AdvisorVisibilityOption[] = [
|
||||
{
|
||||
value: 'carteira',
|
||||
label: 'Carteira',
|
||||
description: 'Municípios que administra',
|
||||
},
|
||||
{
|
||||
value: 'tudo',
|
||||
label: 'Tudo',
|
||||
description: 'Enxerga todos os municípios e lideranças',
|
||||
},
|
||||
]
|
||||
|
||||
export const ADVISOR_EDITING_OPTIONS: readonly AdvisorEditingOption[] = [
|
||||
{
|
||||
value: 'carteira',
|
||||
label: 'Edita carteira',
|
||||
description: 'Edita só o que administra',
|
||||
},
|
||||
{
|
||||
value: 'tudo',
|
||||
label: 'Edita tudo',
|
||||
description:
|
||||
'Edita tudo o que vê — contas, carteiras, nível de envolvimento e demandas escaladas seguem com a coordenação',
|
||||
},
|
||||
{
|
||||
value: 'somente_leitura',
|
||||
label: 'Somente leitura',
|
||||
description: 'Nenhum controle de edição em lugar nenhum',
|
||||
},
|
||||
]
|
||||
|
||||
const visibilityLabel: Record<AdvisorVisibility, string> = Object.fromEntries(
|
||||
ADVISOR_VISIBILITY_OPTIONS.map(({ value, label }) => [value, label]),
|
||||
) as Record<AdvisorVisibility, string>
|
||||
|
||||
const editingLabel: Record<AdvisorEditing, string> = Object.fromEntries(
|
||||
ADVISOR_EDITING_OPTIONS.map(({ value, label }) => [value, label]),
|
||||
) as Record<AdvisorEditing, string>
|
||||
|
||||
/** Short badge label, e.g. "Tudo · Somente leitura". */
|
||||
export const advisorProfileLabel = (
|
||||
visibility: AdvisorVisibility,
|
||||
editing: AdvisorEditing,
|
||||
): string => `${visibilityLabel[visibility]} · ${editingLabel[editing]}`
|
||||
|
||||
/**
|
||||
* The incoherent combination (editing more than one sees) is never offered in
|
||||
* the UI and is rejected server-side too — `editing === 'tudo'` requires
|
||||
* `visibility === 'tudo'`.
|
||||
*/
|
||||
export const isCoherentAdvisorProfile = (
|
||||
visibility: AdvisorVisibility,
|
||||
editing: AdvisorEditing,
|
||||
): boolean => editing !== 'tudo' || visibility === 'tudo'
|
||||
|
||||
export const isAdvisorVisibilityValue = (value: unknown): value is AdvisorVisibility =>
|
||||
ADVISOR_VISIBILITY_VALUES.includes(value as AdvisorVisibility)
|
||||
|
||||
export const isAdvisorEditingValue = (value: unknown): value is AdvisorEditing =>
|
||||
ADVISOR_EDITING_VALUES.includes(value as AdvisorEditing)
|
||||
|
|
@ -1,10 +1,5 @@
|
|||
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)
|
||||
|
|
@ -17,16 +12,6 @@ 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?: (
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@ export const ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE =
|
|||
'Apenas o Coordenador Geral ou o Candidato podem remarcar compromisso com deputado presente.'
|
||||
export const ACTIVITY_RESCHEDULE_FAILED_MESSAGE =
|
||||
'Não foi possível remarcar a atividade. O horário anterior foi mantido.'
|
||||
export const ACTIVITY_OUT_OF_SCOPE_MESSAGE =
|
||||
'O município escolhido está fora do seu escopo de edição. Atualize a página e tente novamente.'
|
||||
|
||||
/**
|
||||
* C14 — tags are free-form labels the mesa invents (comício, imprensa, etc.).
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
ADVISOR_EDITING_VALUES,
|
||||
ADVISOR_VISIBILITY_VALUES,
|
||||
isCoherentAdvisorProfile,
|
||||
} from '@/lib/campaignAdvisorProfile'
|
||||
import {
|
||||
MAX_MUNICIPALITIES_PER_BATCH,
|
||||
MUNICIPALITY_ADVISORS_CAP_MESSAGE,
|
||||
|
|
@ -71,8 +66,6 @@ export const ADVISOR_ROLE_REQUIRED_MESSAGE =
|
|||
'Só é possível gerenciar contas com papel de Assessor nesta tela.'
|
||||
export const ADVISOR_SELF_ACCOUNT_MESSAGE = 'Use Meu perfil para alterar a própria conta.'
|
||||
|
||||
export const ADVISOR_PROFILE_COHERENCE_MESSAGE = 'Edição "Tudo" exige Visão "Tudo".'
|
||||
|
||||
export const ADVISOR_ACTION_SAFE_MESSAGES = [
|
||||
ADVISOR_UNRESTRICTED_MESSAGE,
|
||||
ADVISOR_EMAIL_CONFLICT_MESSAGE,
|
||||
|
|
@ -80,37 +73,14 @@ export const ADVISOR_ACTION_SAFE_MESSAGES = [
|
|||
ADVISOR_ROLE_REQUIRED_MESSAGE,
|
||||
ADVISOR_SELF_ACCOUNT_MESSAGE,
|
||||
MUNICIPALITY_ADVISORS_CAP_MESSAGE,
|
||||
ADVISOR_PROFILE_COHERENCE_MESSAGE,
|
||||
] as const
|
||||
|
||||
/**
|
||||
* C141 — the advisor permission profile (Visão × Edição). Values come from the
|
||||
* single vocabulary in `campaignAdvisorProfile.ts`; the incoherent combination
|
||||
* (Edição "Tudo" without Visão "Tudo") is rejected here AND by the collection's
|
||||
* `enforceCoherentAdvisorProfile` beforeValidate hook (REST/admin included).
|
||||
*/
|
||||
const advisorVisibilitySchema = z.enum(ADVISOR_VISIBILITY_VALUES)
|
||||
|
||||
const advisorEditingSchema = z.enum(ADVISOR_EDITING_VALUES)
|
||||
|
||||
export const advisorCreateSchema = z.object({
|
||||
name: advisorNameSchema,
|
||||
email: z.email('E-mail inválido.'),
|
||||
phone: optionalAdvisorPhone,
|
||||
visibility: advisorVisibilitySchema.default('carteira'),
|
||||
editing: advisorEditingSchema.default('carteira'),
|
||||
})
|
||||
|
||||
export const advisorPermissionUpdateSchema = z
|
||||
.object({
|
||||
id: positiveRelationshipId,
|
||||
visibility: advisorVisibilitySchema,
|
||||
editing: advisorEditingSchema,
|
||||
})
|
||||
.refine((value) => isCoherentAdvisorProfile(value.visibility, value.editing), {
|
||||
message: ADVISOR_PROFILE_COHERENCE_MESSAGE,
|
||||
})
|
||||
|
||||
export const advisorProfileUpdateSchema = z
|
||||
.object({
|
||||
id: positiveRelationshipId,
|
||||
|
|
@ -142,4 +112,3 @@ export type AdvisorCreateInput = z.input<typeof advisorCreateSchema>
|
|||
export type AdvisorProfileUpdateInput = z.input<typeof advisorProfileUpdateSchema>
|
||||
export type AdvisorMunicipalitiesBatchInput = z.input<typeof advisorMunicipalitiesBatchSchema>
|
||||
export type AdvisorPasswordResetInput = z.input<typeof advisorPasswordResetSchema>
|
||||
export type AdvisorPermissionUpdateInput = z.input<typeof advisorPermissionUpdateSchema>
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
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";`)
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_campaign_user_visibility" AS ENUM('carteira', 'tudo');
|
||||
CREATE TYPE "public"."enum_campaign_user_editing" AS ENUM('carteira', 'tudo', 'somente_leitura');
|
||||
ALTER TABLE "campaign_user" ADD COLUMN "visibility" "enum_campaign_user_visibility" DEFAULT 'carteira';
|
||||
ALTER TABLE "campaign_user" ADD COLUMN "editing" "enum_campaign_user_editing" DEFAULT 'carteira';`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "campaign_user" DROP COLUMN "visibility";
|
||||
ALTER TABLE "campaign_user" DROP COLUMN "editing";
|
||||
DROP TYPE "public"."enum_campaign_user_visibility";
|
||||
DROP TYPE "public"."enum_campaign_user_editing";`)
|
||||
}
|
||||
|
|
@ -57,8 +57,6 @@ 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';
|
||||
|
||||
|
|
@ -354,28 +352,18 @@ 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',
|
||||
},
|
||||
{
|
||||
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,
|
||||
down: migration_20260819_214755_add_campaign_user_advisor_permission_profile.down,
|
||||
name: '20260819_214755_add_campaign_user_advisor_permission_profile',
|
||||
name: '20260819_192605_add_campaign_newsletter_capture'
|
||||
},
|
||||
{
|
||||
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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -273,14 +273,6 @@ export interface CampaignUser {
|
|||
name: string;
|
||||
avatar?: (number | null) | Media;
|
||||
role: 'coordinator' | 'advisor' | 'candidate' | 'leader';
|
||||
/**
|
||||
* O que o assessor enxerga no /campanha.
|
||||
*/
|
||||
visibility?: ('carteira' | 'tudo') | null;
|
||||
/**
|
||||
* O que o assessor pode editar no /campanha.
|
||||
*/
|
||||
editing?: ('carteira' | 'tudo' | 'somente_leitura') | null;
|
||||
phone?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
|
|
@ -1550,8 +1542,6 @@ export interface CampaignUserSelect<T extends boolean = true> {
|
|||
name?: T;
|
||||
avatar?: T;
|
||||
role?: T;
|
||||
visibility?: T;
|
||||
editing?: T;
|
||||
phone?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
|
|
@ -2293,12 +2283,6 @@ 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;
|
||||
|
|
@ -2493,11 +2477,6 @@ export interface SiteSettingsSelect<T extends boolean = true> {
|
|||
enabled?: T;
|
||||
id?: T;
|
||||
};
|
||||
tracking?:
|
||||
| T
|
||||
| {
|
||||
facebookPixelId?: T;
|
||||
};
|
||||
_status?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,11 @@
|
|||
// Activities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { Access, FieldAccess, PayloadRequest, Where } from 'payload'
|
||||
import type { Access, FieldAccess, Where } from 'payload'
|
||||
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
|
||||
import {
|
||||
advisorEditingAccess,
|
||||
advisorMunicipalityScopeWhere,
|
||||
getFreshCampaignUser,
|
||||
isCampaignLeader,
|
||||
|
|
@ -22,47 +21,13 @@ const canStaffCreateActivity: FieldAccess = async ({ req }) => {
|
|||
return isCampaignStaff(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
/**
|
||||
* C141 — `canCreateActivity` is a plain staff boolean (Payload cannot express
|
||||
* a per-município constraint on create), so the Edição axis narrows it for
|
||||
* advisors: a `somente_leitura` advisor creates nothing. The giro batch checks
|
||||
* the stops against the WRITE scope on top (`getWritableMunicipalityIds`).
|
||||
*/
|
||||
export const canCreateActivity: Access = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (!currentUser || !isCampaignStaff(currentUser)) return false
|
||||
if (currentUser.role === 'advisor') return advisorEditingAccess(currentUser) !== 'none'
|
||||
|
||||
return true
|
||||
}
|
||||
export const canCreateActivity: Access = canStaffCreateActivity
|
||||
|
||||
export const canCampaignUserRescheduleActivity = (
|
||||
user: CampaignUser,
|
||||
deputyPresent: boolean,
|
||||
): boolean => isCampaignStaff(user) && (!deputyPresent || isCampaignUnrestricted(user))
|
||||
|
||||
const advisorActivityScopeWhere = async (
|
||||
req: PayloadRequest,
|
||||
currentUser: CampaignUser,
|
||||
): Promise<Where> => {
|
||||
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
|
||||
return {
|
||||
or: [
|
||||
// C90 — polymorphic `responsible`: the only scalar query the adapter
|
||||
// supports on a multi-relation relationship is the object notation
|
||||
// with `equals` (see @payloadcms/drizzle sanitizeQueryValue).
|
||||
{
|
||||
responsible: {
|
||||
equals: { relationTo: 'campaignUser', value: currentUser.id },
|
||||
},
|
||||
},
|
||||
advisorMunicipalityScopeWhere('municipality', municipalityIDs),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export const canReadActivity: Access = async ({ req }): Promise<boolean | Where> => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
|
|
@ -72,34 +37,27 @@ export const canReadActivity: Access = async ({ req }): Promise<boolean | Where>
|
|||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
|
||||
if (currentUser.role === 'advisor') {
|
||||
// C141 — Visão "Tudo": the advisor sees the whole activity agenda.
|
||||
if (currentUser.visibility === 'tudo') return true
|
||||
return advisorActivityScopeWhere(req, currentUser)
|
||||
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
|
||||
return {
|
||||
or: [
|
||||
// C90 — polymorphic `responsible`: the only scalar query the adapter
|
||||
// supports on a multi-relation relationship is the object notation
|
||||
// with `equals` (see @payloadcms/drizzle sanitizeQueryValue).
|
||||
{
|
||||
responsible: {
|
||||
equals: { relationTo: 'campaignUser', value: currentUser.id },
|
||||
},
|
||||
},
|
||||
advisorMunicipalityScopeWhere('municipality', municipalityIDs),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* C141 — the Edição axis rules activity updates. `tudo` widens to every
|
||||
* activity; `somente_leitura` closes updates entirely; the carteira branch
|
||||
* keeps the same row scope as read (responsible OR municipality).
|
||||
*/
|
||||
export const canUpdateActivity: Access = async ({ req }): Promise<boolean | Where> => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (!currentUser) return false
|
||||
if (isCampaignLeader(currentUser)) return false
|
||||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
if (currentUser.role !== 'advisor') return false
|
||||
|
||||
const editingAccess = advisorEditingAccess(currentUser)
|
||||
if (editingAccess === 'none') return false
|
||||
if (editingAccess === 'tudo') return true
|
||||
|
||||
return advisorActivityScopeWhere(req, currentUser)
|
||||
}
|
||||
/** Same row scope as read — leader write limits are enforced in `beforeChange`. */
|
||||
export const canUpdateActivity: Access = canReadActivity
|
||||
|
||||
export const canDeleteActivity: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,44 +1,5 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// Allocation decisions (E14 movements, triage outcomes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { Access } from 'payload'
|
||||
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
|
||||
import {
|
||||
advisorEditingAccess,
|
||||
getFreshCampaignUser,
|
||||
isCampaignLeader,
|
||||
isCampaignUnrestricted,
|
||||
isPayloadAdmin,
|
||||
} from '@/utilities/access/shared'
|
||||
|
||||
export {
|
||||
canCreateMunicipalityUpdate as canCreateAllocationDecision,
|
||||
canMutateMunicipalityUpdate as canMutateAllocationDecision,
|
||||
canReadMunicipalityUpdate as canReadAllocationDecision,
|
||||
} from '@/utilities/access/municipalityUpdates'
|
||||
|
||||
/**
|
||||
* C141 — allocation decisions record coordination movements (E14) and triage
|
||||
* outcomes: Edição "Tudo" does NOT widen the create scope, unlike the generic
|
||||
* municipality-update create the old alias inherited. The carteira stays the
|
||||
* ceiling for advisors on BOTH profile axes; `somente_leitura` closes the
|
||||
* write path entirely. The E14 action itself stays unrestricted-gated.
|
||||
*/
|
||||
export const canCreateAllocationDecision: Access = async ({ data, req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (!currentUser || isCampaignLeader(currentUser)) return false
|
||||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
|
||||
const editingAccess = advisorEditingAccess(currentUser)
|
||||
if (editingAccess === 'none') return false
|
||||
|
||||
const municipalityID = relationshipId(data?.municipality)
|
||||
if (!municipalityID) return false
|
||||
|
||||
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
|
||||
return municipalityIDs?.includes(municipalityID) ?? false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,11 +30,7 @@ const getAccessibleContactIds = (
|
|||
? {
|
||||
and: [{ user: { equals: currentUser.id } }, { supportStatus: { equals: 'engajado' } }],
|
||||
}
|
||||
: // C141 — Visão "Tudo" widens the leadership-derived part of the people
|
||||
// list; the supporter-derived part below stays carteira (PII cap).
|
||||
currentUser.role === 'advisor' && currentUser.visibility === 'tudo'
|
||||
? {}
|
||||
: advisorMunicipalityScopeWhere('municipalities', municipalityIDs)
|
||||
: advisorMunicipalityScopeWhere('municipalities', municipalityIDs)
|
||||
|
||||
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
|
||||
const collections = req.payload.collections as Record<string, unknown>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import type { Access, Where } from 'payload'
|
|||
import { relationshipId } from '@/lib/relationship'
|
||||
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
|
||||
import {
|
||||
advisorEditingAccess,
|
||||
getFreshCampaignUser,
|
||||
isCampaignLeader,
|
||||
isCampaignUnrestricted,
|
||||
|
|
@ -25,9 +24,6 @@ export const canCreateCampaignDemand: Access = async ({ data, req }) => {
|
|||
if (!municipalityID) return false
|
||||
|
||||
if (currentUser.role !== 'advisor') return false
|
||||
// C141 — `somente_leitura` creates nothing. Edição "Tudo" does NOT widen
|
||||
// demands: they follow the responsibility rule (C143 owns it).
|
||||
if (advisorEditingAccess(currentUser) === 'none') return false
|
||||
|
||||
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
|
||||
return municipalityIDs?.includes(municipalityID) ?? false
|
||||
|
|
@ -53,18 +49,9 @@ export const canReadCampaignDemand: Access = async ({ req }): Promise<boolean |
|
|||
}
|
||||
|
||||
/**
|
||||
* C141 — demands stay out of the profile axes (C143 replaces this rule with
|
||||
* explicit responsibles): Edição "Tudo" does not widen the row scope, and
|
||||
* `somente_leitura` closes updates entirely.
|
||||
* Staff manage demands they can see — same row scope as read, which is also
|
||||
* who may manage the `responsibles` list (C143).
|
||||
*/
|
||||
export const canUpdateCampaignDemand: Access = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
if (advisorEditingAccess(currentUser) === 'none') return false
|
||||
|
||||
return canReadCampaignDemand({ req })
|
||||
}
|
||||
export const canUpdateCampaignDemand: Access = canReadCampaignDemand
|
||||
|
||||
export const canDeleteCampaignDemand: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
|
|
|||
|
|
@ -8,13 +8,12 @@ import { relationshipId } from '@/lib/relationship'
|
|||
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
|
||||
import type { CampaignActor, DynamicFind } from '@/utilities/access/shared'
|
||||
import {
|
||||
advisorEditingAccess,
|
||||
advisorMunicipalityScopeWhere,
|
||||
getFreshCampaignUser,
|
||||
isCampaignUnrestricted,
|
||||
isPayloadAdmin,
|
||||
resolveAccessibleIds,
|
||||
resolveProfileScopedRead,
|
||||
resolveActorScopedRead,
|
||||
} from '@/utilities/access/shared'
|
||||
|
||||
type LeadershipID = number
|
||||
|
|
@ -29,7 +28,7 @@ const municipalityIDsFromData = (value: unknown): number[] =>
|
|||
(Array.isArray(value) ? value : []).map(relationshipId).filter((id): id is number => id !== null)
|
||||
|
||||
export const canReadLeadership: Access = ({ req }) =>
|
||||
resolveProfileScopedRead(req, 'municipalities', getAccessibleMunicipalityIds)
|
||||
resolveActorScopedRead(req, 'municipalities', getAccessibleMunicipalityIds)
|
||||
|
||||
export const canCreateLeadership: Access = async ({ data, req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
|
@ -38,12 +37,8 @@ export const canCreateLeadership: Access = async ({ data, req }) => {
|
|||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
if (currentUser?.role !== 'advisor') return false
|
||||
|
||||
const editingAccess = advisorEditingAccess(currentUser)
|
||||
if (editingAccess === 'none') return false
|
||||
|
||||
const requestedMunicipalityIDs = municipalityIDsFromData(data?.municipalities)
|
||||
if (requestedMunicipalityIDs.length === 0) return false
|
||||
if (editingAccess === 'tudo') return true
|
||||
|
||||
const accessibleMunicipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
|
||||
if (accessibleMunicipalityIDs === null) return true
|
||||
|
|
@ -58,10 +53,6 @@ export const canManageLeadership: Access = async ({ req }) => {
|
|||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
if (currentUser?.role !== 'advisor') return false
|
||||
|
||||
const editingAccess = advisorEditingAccess(currentUser)
|
||||
if (editingAccess === 'none') return false
|
||||
if (editingAccess === 'tudo') return true
|
||||
|
||||
return advisorMunicipalityScopeWhere(
|
||||
'municipalities',
|
||||
await getAccessibleMunicipalityIds(req, currentUser),
|
||||
|
|
@ -71,10 +62,9 @@ export const canManageLeadership: Access = async ({ req }) => {
|
|||
export const canDeleteLeadership: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
||||
/**
|
||||
* Returns null for unrestricted roles and for advisors with Visão "Tudo"
|
||||
* (C141 — the whole leadership catalog), otherwise the leadership IDs in the
|
||||
* actor's scope: own engaged record for a leader, leaderships linked to
|
||||
* administered municipalities for a carteira advisor.
|
||||
* Returns null for unrestricted roles, otherwise the leadership IDs
|
||||
* in the actor's scope: own engaged record for a leader, leaderships linked to
|
||||
* administered municipalities for an advisor.
|
||||
*/
|
||||
export const getAccessibleLeadershipIds = (
|
||||
req: PayloadRequest,
|
||||
|
|
@ -83,11 +73,9 @@ export const getAccessibleLeadershipIds = (
|
|||
resolveAccessibleIds(req, user, ACCESSIBLE_LEADERSHIP_IDS_MEMO_KEY, async (currentUser) => {
|
||||
const collections = req.payload.collections as Record<string, unknown>
|
||||
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
|
||||
let ids: LeadershipID[] | null = []
|
||||
let ids: LeadershipID[] = []
|
||||
|
||||
if (currentUser.role === 'advisor' && collections.leadership) {
|
||||
if (currentUser.visibility === 'tudo') return null
|
||||
|
||||
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
|
||||
const result = await find({
|
||||
collection: 'leadership',
|
||||
|
|
@ -105,5 +93,5 @@ export const getAccessibleLeadershipIds = (
|
|||
.filter((id): id is number => id !== null)
|
||||
}
|
||||
|
||||
return [...new Set(ids ?? [])]
|
||||
return [...new Set(ids)]
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,14 +5,12 @@
|
|||
import type { Access, FieldAccess, Payload, PayloadRequest } from 'payload'
|
||||
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import type {
|
||||
CampaignActor,
|
||||
CampaignTransactionRequest,
|
||||
DynamicFind,
|
||||
} from '@/utilities/access/shared'
|
||||
import {
|
||||
advisorEditingAccess,
|
||||
getFreshCampaignUser,
|
||||
isCampaignLeader,
|
||||
isCampaignUnrestricted,
|
||||
|
|
@ -160,32 +158,6 @@ export const getAccessibleMunicipalityIds = (
|
|||
return [...new Set(ids)]
|
||||
})
|
||||
|
||||
/**
|
||||
* C141 — municipality IDs an advisor may WRITE under the Edição axis: `null`
|
||||
* (whole catalog) for unrestricted staff and `editing === 'tudo'`, the carteira
|
||||
* ids for `editing === 'carteira'`, `[]` for `somente_leitura`. Takes the FRESH
|
||||
* user (callers inside transactions already reloaded it) so it works with only
|
||||
* a `{ transactionID }` request, mirroring `getAdvisorMunicipalityIds`. The
|
||||
* tour (giro) batch check uses this because `canCreateActivity` is a plain
|
||||
* staff boolean and cannot express a per-município constraint on create.
|
||||
* Deliberately distinct from `getAccessibleMunicipalityIds` (which stays the
|
||||
* carteira scope): the latter feeds PII surfaces — supporters, account phones,
|
||||
* feeds — that the gate caps on the carteira on BOTH profile axes.
|
||||
*/
|
||||
export const getWritableMunicipalityIds = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
currentUser: CampaignUser,
|
||||
req?: CampaignTransactionRequest,
|
||||
): Promise<AccessibleMunicipalityIDs> => {
|
||||
if (isCampaignUnrestricted(currentUser)) return null
|
||||
|
||||
const editingAccess = advisorEditingAccess(currentUser)
|
||||
if (editingAccess === 'none') return []
|
||||
if (editingAccess === 'tudo') return null
|
||||
|
||||
return getAdvisorMunicipalityIds(payload, currentUser.id, req)
|
||||
}
|
||||
|
||||
/** Municipalities are seeded by migration; nobody creates or deletes them in the app. */
|
||||
export const canCreateMunicipality: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
||||
|
|
@ -196,9 +168,6 @@ export const canReadMunicipality: Access = async ({ req }) => {
|
|||
// Editors (and any non-campaign actor) must not see Municípios in /admin.
|
||||
if (!currentUser) return false
|
||||
if (isCampaignLeader(currentUser)) return false
|
||||
// C141 — Visão "Tudo": the advisor sees the whole catalog (panorama,
|
||||
// cobertura); supporters, demands and feeds keep their own narrower gates.
|
||||
if (currentUser.role === 'advisor' && currentUser.visibility === 'tudo') return true
|
||||
|
||||
const ids = await getAccessibleMunicipalityIds(req, currentUser)
|
||||
if (ids === null) return true
|
||||
|
|
@ -216,10 +185,7 @@ export const canUpdateMunicipality: Access = async ({ req }) => {
|
|||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (!currentUser) return false
|
||||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
|
||||
const editingAccess = advisorEditingAccess(currentUser)
|
||||
if (editingAccess === 'none') return false
|
||||
if (editingAccess === 'tudo') return true
|
||||
if (currentUser.role !== 'advisor') return false
|
||||
|
||||
return {
|
||||
advisors: {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,11 @@ import type { Access, FieldAccess } from 'payload'
|
|||
import { relationshipId } from '@/lib/relationship'
|
||||
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
|
||||
import {
|
||||
advisorEditingAccess,
|
||||
getFreshCampaignUser,
|
||||
isCampaignLeader,
|
||||
isCampaignUnrestricted,
|
||||
isPayloadAdmin,
|
||||
resolveProfileScopedRead,
|
||||
resolveActorScopedRead,
|
||||
} from '@/utilities/access/shared'
|
||||
|
||||
export const canCreateMunicipalityUpdate: Access = async ({ data, req }) => {
|
||||
|
|
@ -22,19 +21,15 @@ export const canCreateMunicipalityUpdate: Access = async ({ data, req }) => {
|
|||
if (!currentUser || isCampaignLeader(currentUser)) return false
|
||||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
|
||||
const editingAccess = advisorEditingAccess(currentUser)
|
||||
if (editingAccess === 'none') return false
|
||||
|
||||
const municipalityID = relationshipId(data?.municipality)
|
||||
if (!municipalityID) return false
|
||||
if (editingAccess === 'tudo') return true
|
||||
|
||||
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
|
||||
return municipalityIDs?.includes(municipalityID) ?? false
|
||||
}
|
||||
|
||||
export const canReadMunicipalityUpdate: Access = ({ req }) =>
|
||||
resolveProfileScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
|
||||
resolveActorScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
|
||||
|
||||
export const canMutateMunicipalityUpdate: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
import type { Access } from 'payload'
|
||||
|
||||
import {
|
||||
advisorEditingAccess,
|
||||
getFreshCampaignUser,
|
||||
isCampaignLeader,
|
||||
isCampaignStaff,
|
||||
|
|
@ -15,13 +14,7 @@ import {
|
|||
export const canCreateOrganization: Access = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (!currentUser || !isCampaignStaff(currentUser)) return false
|
||||
// C141 — organizations are already staff-wide today; the Edição axis only
|
||||
// narrows: a `somente_leitura` advisor creates nothing.
|
||||
if (currentUser.role === 'advisor') return advisorEditingAccess(currentUser) !== 'none'
|
||||
|
||||
return true
|
||||
return isCampaignStaff(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
export const canReadOrganization: Access = async ({ req }) => {
|
||||
|
|
@ -37,12 +30,7 @@ export const canReadOrganization: Access = async ({ req }) => {
|
|||
export const canManageOrganization: Access = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (!currentUser || !isCampaignStaff(currentUser)) return false
|
||||
// C141 — same axis as create: `somente_leitura` advisors write nothing.
|
||||
if (currentUser.role === 'advisor') return advisorEditingAccess(currentUser) !== 'none'
|
||||
|
||||
return true
|
||||
return isCampaignStaff(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
export const canDeleteOrganization: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
|
|
|||
|
|
@ -197,59 +197,17 @@ export const resolveActorScopedRead = async (
|
|||
return advisorMunicipalityScopeWhere(scopeField, await loadAccessibleIds(req, currentUser))
|
||||
}
|
||||
|
||||
/**
|
||||
* C141 — same scoped-read prologue as `resolveActorScopedRead`, but the
|
||||
* advisor's configured Visão profile widens the scope: `tudo` → the whole
|
||||
* catalog. Only NON-sensitive collections use this variant — demands (C143
|
||||
* owns the rule) and supporters (PII cap) keep the carteira-only prologue, so
|
||||
* Visão "Tudo" can never open them.
|
||||
*/
|
||||
export const resolveProfileScopedRead = async (
|
||||
req: PayloadRequest,
|
||||
scopeField: 'municipality' | 'municipalities',
|
||||
loadAccessibleIds: (
|
||||
req: PayloadRequest,
|
||||
user: CampaignActor,
|
||||
) => Promise<readonly number[] | null>,
|
||||
): Promise<boolean | Where> => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (isCampaignLeader(currentUser)) return false
|
||||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
if (!currentUser || currentUser.role !== 'advisor') return false
|
||||
if (currentUser.visibility === 'tudo') return true
|
||||
|
||||
return advisorMunicipalityScopeWhere(scopeField, await loadAccessibleIds(req, currentUser))
|
||||
}
|
||||
|
||||
/**
|
||||
* C141 — the advisor's Edição axis as a write-scope decision. `somente_leitura`
|
||||
* → 'none' (no writes anywhere), `tudo` → 'tudo' (writes across the visible
|
||||
* catalog, minus coordination fields), `carteira` → today's portfolio scope.
|
||||
* Callers resolve admin/unrestricted first and keep their own where shape for
|
||||
* the carteira branch; non-advisor actors resolve 'none' here and are handled
|
||||
* by their own branches.
|
||||
*/
|
||||
export const advisorEditingAccess = (user: CampaignActor): 'none' | 'carteira' | 'tudo' => {
|
||||
if (!isCampaignUser(user) || user.role !== 'advisor') return 'none'
|
||||
if (user.editing === 'somente_leitura') return 'none'
|
||||
if (user.editing === 'tudo') return 'tudo'
|
||||
return 'carteira'
|
||||
}
|
||||
|
||||
/**
|
||||
* The memoized "accessible ids" engine the three scope resolvers share
|
||||
* (municipalities, leaderships, contacts). Owns the fresh-user one-liner, the
|
||||
* non-campaign → `[]` and unrestricted → `null` conventions, and the per-request
|
||||
* memo key; `compute` runs the domain-specific query and may itself return
|
||||
* `null` (C141 — an advisor with Visão "Tudo" resolves to the whole catalog).
|
||||
* memo key; `compute` runs the domain-specific query.
|
||||
*/
|
||||
export const resolveAccessibleIds = async <ID>(
|
||||
req: PayloadRequest,
|
||||
user: CampaignActor = req.user,
|
||||
memoKey: string,
|
||||
compute: (currentUser: CampaignUser) => Promise<ID[] | null>,
|
||||
compute: (currentUser: CampaignUser) => Promise<ID[]>,
|
||||
): Promise<ID[] | null> => {
|
||||
const currentUser =
|
||||
isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user
|
||||
|
|
|
|||
|
|
@ -4,23 +4,12 @@
|
|||
|
||||
import type { Access } from 'payload'
|
||||
|
||||
import {
|
||||
advisorEditingAccess,
|
||||
getFreshCampaignUser,
|
||||
isCampaignStaff,
|
||||
isPayloadAdmin,
|
||||
} from '@/utilities/access/shared'
|
||||
import { getFreshCampaignUser, isCampaignStaff, isPayloadAdmin } from '@/utilities/access/shared'
|
||||
|
||||
export const canCreateStateDeputy: Access = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (!currentUser || !isCampaignStaff(currentUser)) return false
|
||||
// C141 — dobradinhas are already staff-wide today; the Edição axis only
|
||||
// narrows: a `somente_leitura` advisor creates nothing.
|
||||
if (currentUser.role === 'advisor') return advisorEditingAccess(currentUser) !== 'none'
|
||||
|
||||
return true
|
||||
return isCampaignStaff(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
export const canReadStateDeputy: Access = async ({ req }) => {
|
||||
|
|
@ -29,15 +18,6 @@ export const canReadStateDeputy: Access = async ({ req }) => {
|
|||
return isCampaignStaff(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
export const canManageStateDeputy: Access = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (!currentUser || !isCampaignStaff(currentUser)) return false
|
||||
// C141 — same axis as create.
|
||||
if (currentUser.role === 'advisor') return advisorEditingAccess(currentUser) !== 'none'
|
||||
|
||||
return true
|
||||
}
|
||||
export const canManageStateDeputy: Access = canReadStateDeputy
|
||||
|
||||
export const canDeleteStateDeputy: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import type { Access, Where } from 'payload'
|
|||
import { relationshipId } from '@/lib/relationship'
|
||||
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
|
||||
import {
|
||||
advisorEditingAccess,
|
||||
advisorMunicipalityScopeWhere,
|
||||
getFreshCampaignUser,
|
||||
isCampaignLeader,
|
||||
|
|
@ -28,11 +27,6 @@ export const canCreateSupporter: Access = async ({ data, req }) => {
|
|||
// must be one `getAccessibleMunicipalityIds` resolves for THEIR role
|
||||
// (administered for the advisor, engaged-leadership-linked for the leader).
|
||||
if (currentUser?.role === 'advisor' || isCampaignLeader(currentUser)) {
|
||||
// C141 — supporters are PII: `somente_leitura` closes the write path, but
|
||||
// Edição "Tudo" never widens beyond the carteira (gate decision).
|
||||
if (currentUser?.role === 'advisor' && advisorEditingAccess(currentUser) === 'none') {
|
||||
return false
|
||||
}
|
||||
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
|
||||
return municipalityIDs?.includes(municipalityID) ?? false
|
||||
}
|
||||
|
|
@ -65,35 +59,6 @@ export const canReadSupporter: Access = async ({ req }): Promise<boolean | Where
|
|||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* C141 — supporter row updates follow the Edição axis on the PII cap: an
|
||||
* advisor may only touch supporters in the carteira (never beyond it), and
|
||||
* `somente_leitura` closes updates entirely.
|
||||
*/
|
||||
export const canManageSupporter: Access = async ({ req }): Promise<boolean | Where> => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (!currentUser) return false
|
||||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
|
||||
if (currentUser.role === 'advisor') {
|
||||
if (advisorEditingAccess(currentUser) === 'none') return false
|
||||
return advisorMunicipalityScopeWhere(
|
||||
'municipality',
|
||||
await getAccessibleMunicipalityIds(req, currentUser),
|
||||
)
|
||||
}
|
||||
|
||||
if (isCampaignLeader(currentUser)) {
|
||||
return {
|
||||
createdBy: {
|
||||
equals: currentUser.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
export const canManageSupporter: Access = canReadSupporter
|
||||
|
||||
export const canDeleteSupporter: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
|
|
|||
|
|
@ -7,13 +7,11 @@ import type { Access } from 'payload'
|
|||
import { relationshipId } from '@/lib/relationship'
|
||||
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
|
||||
import {
|
||||
advisorEditingAccess,
|
||||
getFreshCampaignUser,
|
||||
isCampaignLeader,
|
||||
isCampaignUnrestricted,
|
||||
isPayloadAdmin,
|
||||
resolveActorScopedRead,
|
||||
resolveProfileScopedRead,
|
||||
} from '@/utilities/access/shared'
|
||||
|
||||
export const canCreateVotePledge: Access = async ({ data, req }) => {
|
||||
|
|
@ -29,10 +27,6 @@ export const canCreateVotePledge: Access = async ({ data, req }) => {
|
|||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
|
||||
if (currentUser.role === 'advisor') {
|
||||
const editingAccess = advisorEditingAccess(currentUser)
|
||||
if (editingAccess === 'none') return false
|
||||
if (editingAccess === 'tudo') return true
|
||||
|
||||
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
|
||||
return municipalityIDs?.includes(municipalityID) ?? false
|
||||
}
|
||||
|
|
@ -41,24 +35,9 @@ export const canCreateVotePledge: Access = async ({ data, req }) => {
|
|||
}
|
||||
|
||||
export const canReadVotePledge: Access = ({ req }) =>
|
||||
resolveProfileScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
|
||||
resolveActorScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
|
||||
|
||||
/**
|
||||
* C141 — the Edição axis rules updates. `tudo` widens to every pledge;
|
||||
* `somente_leitura` closes updates entirely; the carteira branch keeps the
|
||||
* same row scope as read.
|
||||
*/
|
||||
export const canUpdateVotePledge: Access = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
|
||||
const editingAccess = advisorEditingAccess(currentUser)
|
||||
if (editingAccess === 'none') return false
|
||||
if (editingAccess === 'tudo') return true
|
||||
|
||||
return resolveActorScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
|
||||
}
|
||||
/** Same row scope as read — the estimated fields are gated by field access. */
|
||||
export const canUpdateVotePledge: Access = canReadVotePledge
|
||||
|
||||
export const canDeleteVotePledge: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'server-only'
|
|||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { AdvisorEditing, AdvisorVisibility } from '@/lib/campaignAdvisorProfile'
|
||||
import type { Contact } from '@/payload-types'
|
||||
|
||||
import { phoneValuesOf } from '@/lib/phone'
|
||||
|
|
@ -35,9 +34,6 @@ type AdvisorAccountViewModel = {
|
|||
*/
|
||||
export type AdvisorRowViewModel = AdvisorAccountViewModel & {
|
||||
municipalityIDs: number[]
|
||||
/** C141 — the advisor permission profile (Visão × Edição). */
|
||||
visibility: AdvisorVisibility
|
||||
editing: AdvisorEditing
|
||||
}
|
||||
|
||||
/** The detail page renders real names and links, so it keeps the labels. */
|
||||
|
|
@ -47,9 +43,6 @@ export type AdvisorDetailViewModel = AdvisorAccountViewModel & {
|
|||
/** Every number of the ficha, order = priority (C112) — primary first. */
|
||||
fichaPhones: string[]
|
||||
municipalities: AdvisorMunicipalityViewModel[]
|
||||
/** C141 — the advisor permission profile (Visão × Edição). */
|
||||
visibility: AdvisorVisibility
|
||||
editing: AdvisorEditing
|
||||
}
|
||||
|
||||
const advisorPageSize = 25
|
||||
|
|
@ -204,7 +197,7 @@ export const loadAdvisorListPageData = async (
|
|||
limit: advisorPageSize,
|
||||
page: state.page,
|
||||
sort: 'name',
|
||||
select: { name: true, email: true, phone: true, role: true, visibility: true, editing: true },
|
||||
select: { name: true, email: true, phone: true, role: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
|
|
@ -218,8 +211,6 @@ export const loadAdvisorListPageData = async (
|
|||
email: doc.email ?? null,
|
||||
phone: doc.phone ?? null,
|
||||
municipalityIDs: municipalityIdsByAdvisor.get(doc.id) ?? [],
|
||||
visibility: doc.visibility ?? 'carteira',
|
||||
editing: doc.editing ?? 'carteira',
|
||||
})),
|
||||
totalDocs: result.totalDocs,
|
||||
totalPages: result.totalPages,
|
||||
|
|
@ -239,24 +230,14 @@ export const loadAdvisorDetail = async (
|
|||
phone?: string | null
|
||||
role: string
|
||||
contact?: number | Contact | null
|
||||
visibility?: AdvisorVisibility | null
|
||||
editing?: AdvisorEditing | null
|
||||
}
|
||||
try {
|
||||
// Intentional admin bypass — same rationale as `loadAdvisorListPageData`.
|
||||
advisor = await payload.findByID({
|
||||
collection: 'campaignUser',
|
||||
id: advisorId,
|
||||
depth: 0,
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
role: true,
|
||||
contact: true,
|
||||
visibility: true,
|
||||
editing: true,
|
||||
},
|
||||
// Intentional admin bypass — same rationale as `loadAdvisorListPageData`.
|
||||
select: { name: true, email: true, phone: true, role: true, contact: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
} catch {
|
||||
|
|
@ -290,7 +271,5 @@ export const loadAdvisorDetail = async (
|
|||
contactID,
|
||||
fichaPhones,
|
||||
municipalities: municipalitiesByAdvisor.get(advisor.id) ?? [],
|
||||
visibility: advisor.visibility ?? 'carteira',
|
||||
editing: advisor.editing ?? 'carteira',
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
*/
|
||||
|
||||
export {
|
||||
advisorEditingAccess,
|
||||
canManagePublishedContent,
|
||||
eligibleCampaignStaffWhere,
|
||||
hasPayloadPanelAccess,
|
||||
|
|
@ -44,7 +43,6 @@ export {
|
|||
getAccessibleMunicipalityIds,
|
||||
getAdvisorMunicipalityIds,
|
||||
getEngagedLeaderMunicipalityIds,
|
||||
getWritableMunicipalityIds,
|
||||
} from '@/utilities/access/municipalities'
|
||||
|
||||
export { canManageContacts, canReadContacts } from '@/utilities/access/contacts'
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import {
|
||||
advisorEditingAccess,
|
||||
getWritableMunicipalityIds,
|
||||
isCampaignUnrestricted,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
|
||||
/**
|
||||
* C141 — the ficha (Contact) write gate shared by the people-cell actions
|
||||
* (C116 `person.ts`) and the contacts-page cells (C139 `contact.ts`). Before
|
||||
* C141 these asserted "you edit what you see" by probing the entities of the
|
||||
* person with the actor's READ access — Visão "Tudo" would have widened the
|
||||
* probe and let a carteira-editing advisor rewrite PII fichas statewide.
|
||||
*
|
||||
* The rule now mirrors the Edição axis: a `somente_leitura` advisor writes no
|
||||
* ficha; every probe leg runs against the WRITE scope — leaderships and
|
||||
* supporters must sit in writable municipalities, dobradinhas keep their
|
||||
* staff-wide status quo. Throws `errorMessage` when no leg matches.
|
||||
*/
|
||||
export const assertPersonContactWritable = async ({
|
||||
payload,
|
||||
actor,
|
||||
contactID,
|
||||
req,
|
||||
errorMessage,
|
||||
}: {
|
||||
payload: Payload
|
||||
actor: CampaignUser
|
||||
contactID: number
|
||||
req: PayloadTransactionRequest
|
||||
errorMessage: string
|
||||
}): Promise<void> => {
|
||||
if (isCampaignUnrestricted(actor)) return
|
||||
if (advisorEditingAccess(actor) === 'none') throw new Error(errorMessage)
|
||||
|
||||
const writable = await getWritableMunicipalityIds(payload, actor, req)
|
||||
const writableFilter = writable === null ? undefined : { in: writable }
|
||||
|
||||
const contactWhere = { contact: { equals: contactID } }
|
||||
const [leaderships, deputies, supporters] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'leadership',
|
||||
where: {
|
||||
and: [contactWhere, ...(writableFilter ? [{ municipalities: writableFilter }] : [])],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: contactWhere,
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'supporter',
|
||||
where: {
|
||||
and: [contactWhere, ...(writableFilter ? [{ municipality: writableFilter }] : [])],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
}),
|
||||
])
|
||||
|
||||
if (leaderships.docs.length === 0 && deputies.docs.length === 0 && supporters.docs.length === 0) {
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
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,8 +1,7 @@
|
|||
import type { APIRequestContext } from '@playwright/test'
|
||||
|
||||
import { CAMPAIGN_NEWSLETTER_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import { adminHeaders } from '../helpers/adminApi'
|
||||
import { seedTestUser } from '../helpers/seedUser'
|
||||
import { seedTestUser, testUser } from '../helpers/seedUser'
|
||||
import { expect, test } from './fixtures/e2eTest'
|
||||
|
||||
/**
|
||||
|
|
@ -23,6 +22,15 @@ 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>,
|
||||
|
|
@ -122,12 +130,12 @@ test.describe('Campaign home novidades capture', () => {
|
|||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await seedTestUser()
|
||||
const headers = await adminHeaders(request, baseURL)
|
||||
const headers = await adminHeaders(request)
|
||||
await createNewsletterConsent(request, headers)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
const headers = await adminHeaders(request, baseURL).catch(() => undefined)
|
||||
const headers = await adminHeaders(request).catch(() => undefined)
|
||||
if (headers) {
|
||||
await cleanup(request, headers)
|
||||
}
|
||||
|
|
@ -165,7 +173,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, baseURL)
|
||||
const headers = await adminHeaders(request)
|
||||
const contacts = await request.get(
|
||||
`${baseURL}/api/contact?where[phones.value][equals]=${successPhone}&limit=1&depth=0`,
|
||||
{ headers },
|
||||
|
|
|
|||
|
|
@ -107,14 +107,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -50,10 +50,7 @@ type CampaignCollection =
|
|||
type OwnedIDs = Record<CampaignCollection, Set<number>>
|
||||
|
||||
type CampaignUserInput = Partial<
|
||||
Pick<
|
||||
CampaignUser,
|
||||
'name' | 'role' | 'phone' | 'email' | 'username' | 'password' | 'visibility' | 'editing'
|
||||
>
|
||||
Pick<CampaignUser, 'name' | 'role' | 'phone' | 'email' | 'username' | 'password'>
|
||||
>
|
||||
type AdminUserInput = {
|
||||
email?: string
|
||||
|
|
|
|||
|
|
@ -10,30 +10,29 @@ 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 })
|
||||
|
||||
try {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
...testUser,
|
||||
roles: ['admin'],
|
||||
// Delete existing test user if any
|
||||
await payload.delete({
|
||||
collection: 'users',
|
||||
where: {
|
||||
email: {
|
||||
equals: testUser.email,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// A parallel worker already seeded the shared user; nothing to reset.
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Create fresh test user
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
...testUser,
|
||||
roles: ['admin'],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,774 +0,0 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload, PayloadRequest, RequiredDataFromCollectionSlug } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createActivityRecord,
|
||||
createTourDraftActivitiesRecord,
|
||||
} from '@/app/(campaign)/campanha/actions/activity'
|
||||
import {
|
||||
createAdvisorRecord,
|
||||
updateAdvisorPermissionRecord,
|
||||
} from '@/app/(campaign)/campanha/actions/advisor'
|
||||
import { createLeadershipRecord } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import {
|
||||
assignMunicipalityAdvisorsRecord,
|
||||
setMunicipalityEngagementLevelRecord,
|
||||
updateMunicipalityStrategyRecord,
|
||||
} from '@/app/(campaign)/campanha/actions/municipality'
|
||||
import config from '@/payload.config'
|
||||
import { canCreateOrganization } from '@/utilities/campaignAccess'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import { stub } from '../helpers/stub'
|
||||
|
||||
type MunicipalityUpdateCreateData = RequiredDataFromCollectionSlug<'municipalityUpdate'>
|
||||
type DemandCreateData = RequiredDataFromCollectionSlug<'campaignDemand'>
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
describe('advisor permission profile (C141)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('creates advisors with the default Carteira · Edita carteira profile', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
|
||||
const created = await createAdvisorRecord(payload, coordinator, {
|
||||
name: 'Assessora Padrão',
|
||||
email: `${fixtures.value('default-profile')}@example.com`,
|
||||
})
|
||||
const stored = await payload.findByID({
|
||||
collection: 'campaignUser',
|
||||
id: created.id,
|
||||
depth: 0,
|
||||
select: { visibility: true, editing: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(stored.visibility).toBe('carteira')
|
||||
expect(stored.editing).toBe('carteira')
|
||||
})
|
||||
|
||||
it('lets coordinator and candidate set the profile; advisor and leader cannot', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const candidate = await fixtures.createCampaignUser('candidate')
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const leader = await fixtures.createCampaignUser('leader')
|
||||
const target = await fixtures.createCampaignUser('advisor')
|
||||
|
||||
const byCoordinator = await updateAdvisorPermissionRecord(payload, coordinator, {
|
||||
id: target.id,
|
||||
visibility: 'tudo',
|
||||
editing: 'somente_leitura',
|
||||
})
|
||||
expect(byCoordinator.visibility).toBe('tudo')
|
||||
expect(byCoordinator.editing).toBe('somente_leitura')
|
||||
|
||||
const byCandidate = await updateAdvisorPermissionRecord(payload, candidate, {
|
||||
id: target.id,
|
||||
visibility: 'tudo',
|
||||
editing: 'carteira',
|
||||
})
|
||||
expect(byCandidate.visibility).toBe('tudo')
|
||||
expect(byCandidate.editing).toBe('carteira')
|
||||
|
||||
await expect(
|
||||
updateAdvisorPermissionRecord(payload, advisor, {
|
||||
id: target.id,
|
||||
visibility: 'tudo',
|
||||
editing: 'carteira',
|
||||
}),
|
||||
).rejects.toThrow(/coordenação geral ou o candidato/i)
|
||||
|
||||
await expect(
|
||||
updateAdvisorPermissionRecord(payload, leader, {
|
||||
id: target.id,
|
||||
visibility: 'carteira',
|
||||
editing: 'carteira',
|
||||
}),
|
||||
).rejects.toThrow(/coordenação geral ou o candidato/i)
|
||||
})
|
||||
|
||||
it('rejects the incoherent combination on the action and on the API itself', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const target = await fixtures.createCampaignUser('advisor')
|
||||
|
||||
await expect(
|
||||
updateAdvisorPermissionRecord(payload, coordinator, {
|
||||
id: target.id,
|
||||
visibility: 'carteira',
|
||||
editing: 'tudo',
|
||||
}),
|
||||
).rejects.toThrow(/exige Visão/)
|
||||
|
||||
// The collection hook is the fail-closed layer for REST/admin writes.
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: 'campaignUser',
|
||||
id: target.id,
|
||||
data: { visibility: 'carteira', editing: 'tudo' },
|
||||
depth: 0,
|
||||
user: coordinator,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow(/exige Visão/)
|
||||
})
|
||||
|
||||
it('prevents advisors from changing their own profile', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: 'campaignUser',
|
||||
id: advisor.id,
|
||||
data: { visibility: 'tudo' },
|
||||
depth: 0,
|
||||
user: advisor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('widens reads to the whole catalog with Visão "Tudo" — municipalities, leaderships, pledges', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const carteiraAdvisor = await fixtures.createCampaignUser('advisor')
|
||||
const wideAdvisor = await fixtures.createCampaignUser('advisor', { visibility: 'tudo' })
|
||||
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const outside = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [carteiraAdvisor.id, wideAdvisor.id])
|
||||
|
||||
const contact = await fixtures.createContact()
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
municipalities: [outside.id],
|
||||
})
|
||||
const pledge = await fixtures.createVotePledge({
|
||||
leadership: leadership.id,
|
||||
municipality: outside.id,
|
||||
})
|
||||
|
||||
const carteiraCount = await payload.count({
|
||||
collection: 'municipality',
|
||||
where: {},
|
||||
user: carteiraAdvisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(carteiraCount.totalDocs).toBe(1)
|
||||
|
||||
const wideCount = await payload.count({
|
||||
collection: 'municipality',
|
||||
where: {},
|
||||
user: wideAdvisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(wideCount.totalDocs).toBe(435)
|
||||
|
||||
// The leadership/pledge pins are by EXACT id (not global counts, which the
|
||||
// shared test DB pollutes from parallel specs): the wide advisor sees the
|
||||
// out-of-carteira row, the carteira advisor does not.
|
||||
const wideLeadership = await payload.findByID({
|
||||
collection: 'leadership',
|
||||
id: leadership.id,
|
||||
depth: 0,
|
||||
user: wideAdvisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(wideLeadership.id).toBe(leadership.id)
|
||||
|
||||
await expect(
|
||||
payload.findByID({
|
||||
collection: 'leadership',
|
||||
id: leadership.id,
|
||||
depth: 0,
|
||||
user: carteiraAdvisor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
const widePledge = await payload.findByID({
|
||||
collection: 'votePledge',
|
||||
id: pledge.id,
|
||||
depth: 0,
|
||||
user: wideAdvisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(widePledge.id).toBe(pledge.id)
|
||||
|
||||
await expect(
|
||||
payload.findByID({
|
||||
collection: 'votePledge',
|
||||
id: pledge.id,
|
||||
depth: 0,
|
||||
user: carteiraAdvisor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('keeps demands and supporters on the carteira even with Visão "Tudo"', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const wideAdvisor = await fixtures.createCampaignUser('advisor', { visibility: 'tudo' })
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const outside = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [wideAdvisor.id])
|
||||
|
||||
const inScopeContact = await fixtures.createContact()
|
||||
const inScopeSupporter = await fixtures.createSupporter({
|
||||
contact: inScopeContact,
|
||||
municipality: administered.id,
|
||||
})
|
||||
const outsideContact = await fixtures.createContact()
|
||||
await fixtures.createSupporter({
|
||||
contact: outsideContact,
|
||||
municipality: outside.id,
|
||||
})
|
||||
await fixtures.createCampaignDemand({
|
||||
municipality: outside.id,
|
||||
title: fixtures.value('demanda-fora'),
|
||||
})
|
||||
|
||||
const visibleSupporters = await payload.find({
|
||||
collection: 'supporter',
|
||||
where: {},
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
user: wideAdvisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(visibleSupporters.docs.map((doc) => doc.id)).toEqual([inScopeSupporter.id])
|
||||
|
||||
const visibleDemands = await payload.find({
|
||||
collection: 'campaignDemand',
|
||||
where: {},
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
user: wideAdvisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(visibleDemands.docs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('writes exactly what Edição allows — carteira keeps the boundary', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor', { visibility: 'tudo' })
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const outside = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
|
||||
|
||||
await expect(
|
||||
updateMunicipalityStrategyRecord(payload, advisor, {
|
||||
municipality: outside.id,
|
||||
priority: 'alta',
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
const updated = await updateMunicipalityStrategyRecord(payload, advisor, {
|
||||
municipality: administered.id,
|
||||
priority: 'alta',
|
||||
})
|
||||
expect(updated.priority).toBe('alta')
|
||||
fixtures.touchMunicipality(administered.id)
|
||||
fixtures.touchMunicipality(outside.id)
|
||||
})
|
||||
|
||||
it('Edição "Tudo" widens staff writes but never the coordination or PII surfaces', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor', {
|
||||
visibility: 'tudo',
|
||||
editing: 'tudo',
|
||||
})
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const outside = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
|
||||
|
||||
const outsideUpdated = await updateMunicipalityStrategyRecord(payload, advisor, {
|
||||
municipality: outside.id,
|
||||
priority: 'alta',
|
||||
})
|
||||
expect(outsideUpdated.priority).toBe('alta')
|
||||
fixtures.touchMunicipality(outside.id)
|
||||
|
||||
// Coordination writes stay unrestricted-only.
|
||||
await expect(
|
||||
assignMunicipalityAdvisorsRecord(payload, advisor, {
|
||||
municipality: outside.id,
|
||||
advisors: [advisor.id],
|
||||
}),
|
||||
).rejects.toThrow(/coordenação geral ou o candidato/i)
|
||||
|
||||
await expect(
|
||||
setMunicipalityEngagementLevelRecord(payload, advisor, {
|
||||
municipality: outside.id,
|
||||
level: 'n2',
|
||||
note: 'Teste C141',
|
||||
}),
|
||||
).rejects.toThrow(/coordenador|candidato|coordenação/i)
|
||||
|
||||
// Supporter rows (PII) stay capped on the carteira even with Edição "Tudo".
|
||||
const outsideContact = await fixtures.createContact()
|
||||
const outsideSupporter = await fixtures.createSupporter({
|
||||
contact: outsideContact,
|
||||
municipality: outside.id,
|
||||
})
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: 'supporter',
|
||||
id: outsideSupporter.id,
|
||||
data: { notes: 'tentativa fora da carteira' },
|
||||
depth: 0,
|
||||
user: advisor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('Somente leitura closes every write path for advisors', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor', {
|
||||
editing: 'somente_leitura',
|
||||
})
|
||||
const administered = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
|
||||
|
||||
await expect(
|
||||
updateMunicipalityStrategyRecord(payload, advisor, {
|
||||
municipality: administered.id,
|
||||
priority: 'alta',
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
// Organizations are staff-wide to create, but the Edição axis closes the
|
||||
// path for a `somente_leitura` advisor (fail-closed on the access gate).
|
||||
const createAsAdvisor = await canCreateOrganization({
|
||||
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
|
||||
data: { name: fixtures.value('org-bloqueada'), kind: 'sindicato' },
|
||||
})
|
||||
expect(createAsAdvisor).toBe(false)
|
||||
|
||||
const contact = await fixtures.createContact()
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
municipalities: [administered.id],
|
||||
})
|
||||
const pledge = await fixtures.createVotePledge({
|
||||
leadership: leadership.id,
|
||||
municipality: administered.id,
|
||||
})
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: 'votePledge',
|
||||
id: pledge.id,
|
||||
data: { declaredVotes: 500 },
|
||||
depth: 0,
|
||||
user: advisor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('giro (tour) stops are checked against the WRITE scope, not the read scope', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor', {
|
||||
visibility: 'tudo',
|
||||
editing: 'carteira',
|
||||
})
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const outside = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
|
||||
|
||||
// Visão "Tudo" lets the advisor READ the outside município, but Edição
|
||||
// "Carteira" must still keep a tour composed over it out of the database.
|
||||
await expect(
|
||||
createTourDraftActivitiesRecord(payload, advisor, {
|
||||
tourName: 'Giro fora da carteira',
|
||||
note: undefined,
|
||||
stops: [{ municipality: outside.id, tags: [] }],
|
||||
}),
|
||||
).rejects.toThrow(/fora do escopo|escopo/i)
|
||||
|
||||
const created = await createTourDraftActivitiesRecord(payload, advisor, {
|
||||
tourName: 'Giro na carteira',
|
||||
note: undefined,
|
||||
stops: [{ municipality: administered.id, tags: [] }],
|
||||
})
|
||||
expect(created).toHaveLength(1)
|
||||
fixtures.touchMunicipality(administered.id)
|
||||
})
|
||||
|
||||
it('giro follows Edição "Tudo" (out-of-carteira stops ok) and Somente leitura (blocked)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const wideAdvisor = await fixtures.createCampaignUser('advisor', {
|
||||
visibility: 'tudo',
|
||||
editing: 'tudo',
|
||||
})
|
||||
const readOnlyAdvisor = await fixtures.createCampaignUser('advisor', {
|
||||
editing: 'somente_leitura',
|
||||
})
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const outside = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [wideAdvisor.id])
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [readOnlyAdvisor.id])
|
||||
|
||||
const wideCreated = await createTourDraftActivitiesRecord(payload, wideAdvisor, {
|
||||
tourName: 'Giro amplo',
|
||||
note: undefined,
|
||||
stops: [{ municipality: outside.id, tags: [] }],
|
||||
})
|
||||
expect(wideCreated).toHaveLength(1)
|
||||
fixtures.touchMunicipality(outside.id)
|
||||
|
||||
await expect(
|
||||
createTourDraftActivitiesRecord(payload, readOnlyAdvisor, {
|
||||
tourName: 'Giro bloqueado',
|
||||
note: undefined,
|
||||
stops: [{ municipality: administered.id, tags: [] }],
|
||||
}),
|
||||
).rejects.toThrow(/fora do escopo|escopo/i)
|
||||
})
|
||||
|
||||
it('activity create/update follow the Edição axis (not the read scope)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const wideReadAdvisor = await fixtures.createCampaignUser('advisor', {
|
||||
visibility: 'tudo',
|
||||
editing: 'carteira',
|
||||
})
|
||||
const wideEditAdvisor = await fixtures.createCampaignUser('advisor', {
|
||||
visibility: 'tudo',
|
||||
editing: 'tudo',
|
||||
})
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const outside = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [wideReadAdvisor.id])
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [wideEditAdvisor.id])
|
||||
|
||||
const validInput = (municipalityId: number, title: string) => ({
|
||||
title,
|
||||
tags: ['Caminhada'],
|
||||
status: 'confirmado' as const,
|
||||
startAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
municipality: municipalityId,
|
||||
locality: 'Centro',
|
||||
})
|
||||
|
||||
// Visão "Tudo" + Edição "Carteira": the single create outside the carteira
|
||||
// is refused by the action (Payload's `canCreateActivity` is a plain staff
|
||||
// boolean, so the per-município check lives in the action — same as the
|
||||
// giro batch).
|
||||
await expect(
|
||||
createActivityRecord(
|
||||
payload,
|
||||
wideReadAdvisor,
|
||||
validInput(outside.id, fixtures.value('atividade-fora')),
|
||||
),
|
||||
).rejects.toThrow()
|
||||
|
||||
// Edição "Tudo" creates and updates outside the carteira.
|
||||
const created = await createActivityRecord(
|
||||
payload,
|
||||
wideEditAdvisor,
|
||||
validInput(outside.id, fixtures.value('atividade-ampla')),
|
||||
)
|
||||
fixtures.touchMunicipality(outside.id)
|
||||
const updated = await payload.update({
|
||||
collection: 'activity',
|
||||
id: created.id,
|
||||
data: { locality: 'Novo local' },
|
||||
depth: 0,
|
||||
user: wideEditAdvisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(updated.locality).toBe('Novo local')
|
||||
|
||||
// A carteira advisor cannot update the same out-of-carteira activity.
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: 'activity',
|
||||
id: created.id,
|
||||
data: { locality: 'Invadido' },
|
||||
depth: 0,
|
||||
user: wideReadAdvisor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('leadership create/manage follow the Edição axis in the server actions', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const wideEditAdvisor = await fixtures.createCampaignUser('advisor', {
|
||||
visibility: 'tudo',
|
||||
editing: 'tudo',
|
||||
})
|
||||
const wideReadAdvisor = await fixtures.createCampaignUser('advisor', {
|
||||
visibility: 'tudo',
|
||||
editing: 'carteira',
|
||||
})
|
||||
const readOnlyAdvisor = await fixtures.createCampaignUser('advisor', {
|
||||
editing: 'somente_leitura',
|
||||
})
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const outside = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [wideEditAdvisor.id])
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [wideReadAdvisor.id])
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [readOnlyAdvisor.id])
|
||||
|
||||
const created = await createLeadershipRecord(payload, wideEditAdvisor, {
|
||||
municipalities: [outside.id],
|
||||
name: fixtures.value('Liderança ampla'),
|
||||
phones: [fixtures.phone()],
|
||||
supportStatus: 'a_abordar',
|
||||
})
|
||||
expect(created.id).toBeTruthy()
|
||||
fixtures.touchMunicipality(outside.id)
|
||||
|
||||
await expect(
|
||||
createLeadershipRecord(payload, readOnlyAdvisor, {
|
||||
municipalities: [administered.id],
|
||||
name: fixtures.value('Liderança bloqueada'),
|
||||
phones: [fixtures.phone()],
|
||||
supportStatus: 'a_abordar',
|
||||
}),
|
||||
).rejects.toThrow(/escopo|municípios que assessora/i)
|
||||
// Update outside the carteira: allowed for Edição "Tudo", denied for carteira.
|
||||
await expect(
|
||||
createLeadershipRecord(payload, wideReadAdvisor, {
|
||||
municipalities: [outside.id],
|
||||
name: fixtures.value('Liderança indevida'),
|
||||
phones: [fixtures.phone()],
|
||||
supportStatus: 'a_abordar',
|
||||
}),
|
||||
).rejects.toThrow(/escopo|municípios que assessora/i)
|
||||
})
|
||||
|
||||
it('municipality updates: Visão "Tudo" reads outside, Edição gates creates', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const wideAdvisor = await fixtures.createCampaignUser('advisor', { visibility: 'tudo' })
|
||||
const carteiraAdvisor = await fixtures.createCampaignUser('advisor')
|
||||
const wideEditAdvisor = await fixtures.createCampaignUser('advisor', {
|
||||
visibility: 'tudo',
|
||||
editing: 'tudo',
|
||||
})
|
||||
const author = await fixtures.createCampaignUser('coordinator')
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const outside = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [wideAdvisor.id])
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [wideEditAdvisor.id])
|
||||
|
||||
const outsideUpdate = await fixtures.createMunicipalityUpdate({
|
||||
municipality: outside.id,
|
||||
author: author.id,
|
||||
body: fixtures.value('atualização fora'),
|
||||
})
|
||||
fixtures.touchMunicipality(outside.id)
|
||||
|
||||
// Visão "Tudo" reads the out-of-carteira update; the carteira advisor does not.
|
||||
const wideRead = await payload.findByID({
|
||||
collection: 'municipalityUpdate',
|
||||
id: outsideUpdate.id,
|
||||
depth: 0,
|
||||
user: wideAdvisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(wideRead.id).toBe(outsideUpdate.id)
|
||||
|
||||
await expect(
|
||||
payload.findByID({
|
||||
collection: 'municipalityUpdate',
|
||||
id: outsideUpdate.id,
|
||||
depth: 0,
|
||||
user: carteiraAdvisor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
// Edição "Tudo" creates an update outside the carteira.
|
||||
await payload.create({
|
||||
collection: 'municipalityUpdate',
|
||||
data: stub<MunicipalityUpdateCreateData>({
|
||||
municipality: outside.id,
|
||||
body: fixtures.value('update amplo'),
|
||||
}),
|
||||
depth: 0,
|
||||
user: wideEditAdvisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('demandas ignoram o eixo de visão e de edição — somente leitura fecha, "Tudo" não alarga', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const wideEditAdvisor = await fixtures.createCampaignUser('advisor', {
|
||||
visibility: 'tudo',
|
||||
editing: 'tudo',
|
||||
})
|
||||
const readOnlyAdvisor = await fixtures.createCampaignUser('advisor', {
|
||||
editing: 'somente_leitura',
|
||||
})
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const outside = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [wideEditAdvisor.id])
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [readOnlyAdvisor.id])
|
||||
|
||||
const demand = await fixtures.createCampaignDemand({
|
||||
municipality: outside.id,
|
||||
title: fixtures.value('demanda-eixo'),
|
||||
})
|
||||
|
||||
// Edição "Tudo" does NOT widen demand updates (C143 owns the rule).
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: 'campaignDemand',
|
||||
id: demand.id,
|
||||
data: { description: 'tentativa' },
|
||||
depth: 0,
|
||||
user: wideEditAdvisor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
// Somente leitura cannot create or update demands, even in the carteira.
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'campaignDemand',
|
||||
data: stub<DemandCreateData>({
|
||||
municipality: administered.id,
|
||||
title: fixtures.value('demanda-ro'),
|
||||
}),
|
||||
depth: 0,
|
||||
user: readOnlyAdvisor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
const inScopeDemand = await fixtures.createCampaignDemand({
|
||||
municipality: administered.id,
|
||||
title: fixtures.value('demanda-carteira'),
|
||||
})
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: 'campaignDemand',
|
||||
id: inScopeDemand.id,
|
||||
data: { description: 'tentativa ro' },
|
||||
depth: 0,
|
||||
user: readOnlyAdvisor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('Visão "Tudo" alarga os contatos de liderança mas mantém apoiadores na carteira (PII cap)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const wideAdvisor = await fixtures.createCampaignUser('advisor', { visibility: 'tudo' })
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const outside = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [wideAdvisor.id])
|
||||
|
||||
const outsideLeadershipContact = await fixtures.createContact()
|
||||
await fixtures.createLeadership({
|
||||
contact: outsideLeadershipContact,
|
||||
municipalities: [outside.id],
|
||||
})
|
||||
const outsideSupporterContact = await fixtures.createContact()
|
||||
await fixtures.createSupporter({
|
||||
contact: outsideSupporterContact,
|
||||
municipality: outside.id,
|
||||
})
|
||||
const insideSupporterContact = await fixtures.createContact()
|
||||
await fixtures.createSupporter({
|
||||
contact: insideSupporterContact,
|
||||
municipality: administered.id,
|
||||
})
|
||||
|
||||
// The leadership ficha outside the carteira IS readable with Visão "Tudo".
|
||||
const leadershipFicha = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: outsideLeadershipContact.id,
|
||||
depth: 0,
|
||||
user: wideAdvisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(leadershipFicha.id).toBe(outsideLeadershipContact.id)
|
||||
|
||||
// The supporter ficha outside the carteira stays invisible (PII cap).
|
||||
await expect(
|
||||
payload.findByID({
|
||||
collection: 'contact',
|
||||
id: outsideSupporterContact.id,
|
||||
depth: 0,
|
||||
user: wideAdvisor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
const insideSupporterFicha = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: insideSupporterContact.id,
|
||||
depth: 0,
|
||||
user: wideAdvisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(insideSupporterFicha.id).toBe(insideSupporterContact.id)
|
||||
})
|
||||
|
||||
it('enforceCoherentAdvisorProfile cobre updates parciais e downgrades', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const target = await fixtures.createCampaignUser('advisor')
|
||||
|
||||
// Partial update raising Edição without touching Visão.
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: 'campaignUser',
|
||||
id: target.id,
|
||||
data: { editing: 'tudo' },
|
||||
depth: 0,
|
||||
user: coordinator,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow(/exige Visão/)
|
||||
|
||||
// Lowering Visão while Edição "Tudo" is persisted.
|
||||
await updateAdvisorPermissionRecord(payload, coordinator, {
|
||||
id: target.id,
|
||||
visibility: 'tudo',
|
||||
editing: 'tudo',
|
||||
})
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: 'campaignUser',
|
||||
id: target.id,
|
||||
data: { visibility: 'carteira' },
|
||||
depth: 0,
|
||||
user: coordinator,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow(/exige Visão/)
|
||||
|
||||
// Create with Edição "Tudo" and no Visão.
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'campaignUser',
|
||||
data: {
|
||||
name: fixtures.value('incoerente'),
|
||||
email: `${fixtures.value('incoerente')}@example.com`,
|
||||
password: fixtures.value('password'),
|
||||
role: 'advisor',
|
||||
editing: 'tudo',
|
||||
},
|
||||
depth: 0,
|
||||
}),
|
||||
).rejects.toThrow(/exige Visão/)
|
||||
})
|
||||
})
|
||||
|
|
@ -5,11 +5,9 @@ import { describe, expect, it, vi } from 'vitest'
|
|||
|
||||
import type { CampaignUser, User } from '@/payload-types'
|
||||
import {
|
||||
advisorEditingAccess,
|
||||
advisorMunicipalityScopeWhere,
|
||||
resolveAccessibleIds,
|
||||
resolveActorScopedRead,
|
||||
resolveProfileScopedRead,
|
||||
} from '@/utilities/access/shared'
|
||||
|
||||
import { stub } from '../helpers/stub'
|
||||
|
|
@ -83,48 +81,4 @@ describe('access shared policies (P3-D)', () => {
|
|||
await expect(resolveAccessibleIds(req, undefined, 'k', compute)).resolves.toEqual([5])
|
||||
expect(compute).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('C141 advisorEditingAccess maps the Edição axis to a scope decision', () => {
|
||||
const withEditing = (editing: CampaignUser['editing']) =>
|
||||
stub<CampaignUser>({ collection: 'campaignUser', id: 9, role: 'advisor', editing })
|
||||
expect(advisorEditingAccess(withEditing('somente_leitura'))).toBe('none')
|
||||
expect(advisorEditingAccess(withEditing('tudo'))).toBe('tudo')
|
||||
expect(advisorEditingAccess(withEditing('carteira'))).toBe('carteira')
|
||||
expect(
|
||||
advisorEditingAccess(
|
||||
stub<CampaignUser>({ collection: 'campaignUser', id: 9, role: 'leader' }),
|
||||
),
|
||||
).toBe('none')
|
||||
expect(advisorEditingAccess(stub<User>({ collection: 'users', roles: ['admin'] }))).toBe('none')
|
||||
expect(advisorEditingAccess(null)).toBe('none')
|
||||
})
|
||||
|
||||
it('C141 resolveProfileScopedRead widens the advisor scope with Visão "Tudo"', async () => {
|
||||
const loadAccessibleIds = vi.fn().mockResolvedValue([42])
|
||||
|
||||
const wideAdvisor = stub<CampaignUser>({
|
||||
collection: 'campaignUser',
|
||||
id: 7,
|
||||
role: 'advisor',
|
||||
visibility: 'tudo',
|
||||
})
|
||||
const wideReq = stub<PayloadRequest>({
|
||||
user: wideAdvisor,
|
||||
payload: stub<Payload>({ findByID: vi.fn().mockResolvedValue(wideAdvisor) }),
|
||||
})
|
||||
await expect(
|
||||
resolveProfileScopedRead(wideReq, 'municipality', loadAccessibleIds),
|
||||
).resolves.toBe(true)
|
||||
// The carteira branch keeps the fragment — and never calls the ids loader
|
||||
// for a visão-tudo advisor.
|
||||
expect(loadAccessibleIds).not.toHaveBeenCalled()
|
||||
|
||||
const carteiraReq = stub<PayloadRequest>({
|
||||
user: advisorUser,
|
||||
payload: stub<Payload>({ findByID: vi.fn().mockResolvedValue(advisorUser) }),
|
||||
})
|
||||
await expect(
|
||||
resolveProfileScopedRead(carteiraReq, 'municipality', loadAccessibleIds),
|
||||
).resolves.toEqual({ municipality: { in: [42] } })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ADVISOR_EDITING_OPTIONS,
|
||||
ADVISOR_VISIBILITY_OPTIONS,
|
||||
advisorProfileLabel,
|
||||
isAdvisorEditingValue,
|
||||
isAdvisorVisibilityValue,
|
||||
isCoherentAdvisorProfile,
|
||||
} from '@/lib/campaignAdvisorProfile'
|
||||
|
||||
describe('campaign advisor profile (C141)', () => {
|
||||
it('labels the badge the same way the UI draft spells it', () => {
|
||||
expect(advisorProfileLabel('carteira', 'carteira')).toBe('Carteira · Edita carteira')
|
||||
expect(advisorProfileLabel('tudo', 'somente_leitura')).toBe('Tudo · Somente leitura')
|
||||
expect(advisorProfileLabel('tudo', 'tudo')).toBe('Tudo · Edita tudo')
|
||||
expect(advisorProfileLabel('carteira', 'somente_leitura')).toBe('Carteira · Somente leitura')
|
||||
expect(advisorProfileLabel('tudo', 'carteira')).toBe('Tudo · Edita carteira')
|
||||
})
|
||||
|
||||
it('rejects the incoherent combination (editing more than one sees)', () => {
|
||||
expect(isCoherentAdvisorProfile('tudo', 'tudo')).toBe(true)
|
||||
expect(isCoherentAdvisorProfile('tudo', 'carteira')).toBe(true)
|
||||
expect(isCoherentAdvisorProfile('tudo', 'somente_leitura')).toBe(true)
|
||||
expect(isCoherentAdvisorProfile('carteira', 'carteira')).toBe(true)
|
||||
expect(isCoherentAdvisorProfile('carteira', 'somente_leitura')).toBe(true)
|
||||
expect(isCoherentAdvisorProfile('carteira', 'tudo')).toBe(false)
|
||||
})
|
||||
|
||||
it('offers every value in the options with pt-BR labels and descriptions', () => {
|
||||
expect(ADVISOR_VISIBILITY_OPTIONS.map((option) => option.value)).toEqual(['carteira', 'tudo'])
|
||||
expect(ADVISOR_EDITING_OPTIONS.map((option) => option.value)).toEqual([
|
||||
'carteira',
|
||||
'tudo',
|
||||
'somente_leitura',
|
||||
])
|
||||
for (const option of [...ADVISOR_VISIBILITY_OPTIONS, ...ADVISOR_EDITING_OPTIONS]) {
|
||||
expect(option.label.length).toBeGreaterThan(0)
|
||||
expect(option.description.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('guards the enum values at the form boundary', () => {
|
||||
expect(isAdvisorVisibilityValue('tudo')).toBe(true)
|
||||
expect(isAdvisorVisibilityValue('todas')).toBe(false)
|
||||
expect(isAdvisorEditingValue('somente_leitura')).toBe(true)
|
||||
expect(isAdvisorEditingValue('readonly')).toBe(false)
|
||||
expect(isAdvisorEditingValue(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -29,12 +29,6 @@ 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', () => {
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
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,9 +433,6 @@ 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,7 +6,6 @@ import {
|
|||
isValidFacebookPixelId,
|
||||
normalizeFacebookPixelId,
|
||||
trackMetaLead,
|
||||
validateFacebookPixelId,
|
||||
} from '@/lib/facebookPixel'
|
||||
|
||||
describe('facebookPixel', () => {
|
||||
|
|
@ -36,24 +35,6 @@ 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()
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ describe('opencode commands couple to their skill by exact name', () => {
|
|||
/^description: .+$/m,
|
||||
)
|
||||
expect(frontmatter![1], 'frontmatter must pin the preference model').toMatch(
|
||||
/^model: deepseek\/deepseek-v4-flash$/m,
|
||||
/^model: cheapestinference\/deepseek-v4-flash$/m,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33)
|
|||
|
||||
it('next launches with the preset model, --auto and the /work-issue command sent (value always quoted)', () => {
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'next', terminal: true })).toBe(
|
||||
`launch opencode ${dir} --model deepseek/deepseek-v4-flash --auto --prompt "/work-issue"`,
|
||||
`launch opencode ${dir} --model cheapestinference/deepseek-v4-flash --auto --prompt "/work-issue"`,
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -156,33 +156,33 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33)
|
|||
expect(
|
||||
opencodeLaunchDirective({ dir, purpose: 'next', terminal: true, issueNumber: 595 }),
|
||||
).toBe(
|
||||
`launch opencode ${dir} --model deepseek/deepseek-v4-flash --auto --prompt "/work-issue --issue 595"`,
|
||||
`launch opencode ${dir} --model cheapestinference/deepseek-v4-flash --auto --prompt "/work-issue --issue 595"`,
|
||||
)
|
||||
})
|
||||
|
||||
it('plan launches with the same presets and /plan-issue sent (OPS31, value quoted)', () => {
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'plan', terminal: true })).toBe(
|
||||
`launch opencode ${dir} --model deepseek/deepseek-v4-flash --auto --prompt "/plan-issue"`,
|
||||
`launch opencode ${dir} --model cheapestinference/deepseek-v4-flash --auto --prompt "/plan-issue"`,
|
||||
)
|
||||
})
|
||||
|
||||
it('new launches prompt-less too — "apenas conversar", no skill sent', () => {
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'new', terminal: true })).toBe(
|
||||
`launch opencode ${dir} --model deepseek/deepseek-v4-flash --auto`,
|
||||
`launch opencode ${dir} --model cheapestinference/deepseek-v4-flash --auto`,
|
||||
)
|
||||
})
|
||||
|
||||
it('plan/new ignore the issueNumber — only next carries the claimed issue', () => {
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'plan', terminal: true, issueNumber: 7 })).toBe(
|
||||
`launch opencode ${dir} --model deepseek/deepseek-v4-flash --auto --prompt "/plan-issue"`,
|
||||
`launch opencode ${dir} --model cheapestinference/deepseek-v4-flash --auto --prompt "/plan-issue"`,
|
||||
)
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'new', terminal: true, issueNumber: 7 })).toBe(
|
||||
`launch opencode ${dir} --model deepseek/deepseek-v4-flash --auto`,
|
||||
`launch opencode ${dir} --model cheapestinference/deepseek-v4-flash --auto`,
|
||||
)
|
||||
})
|
||||
|
||||
it('pins the preset constants — changing the model is editing a constant', () => {
|
||||
expect(OPENCODE_PRESET_MODEL).toBe('deepseek/deepseek-v4-flash')
|
||||
expect(OPENCODE_PRESET_MODEL).toBe('cheapestinference/deepseek-v4-flash')
|
||||
expect(WORKTREE_TERMINAL_ENV).toBe('TEQO_WORKTREE_TERMINAL')
|
||||
expect(OPENCODE_SKILL_COMMAND_BY_PURPOSE).toEqual({
|
||||
next: '/work-issue',
|
||||
|
|
@ -193,7 +193,7 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33)
|
|||
|
||||
it('an unknown purpose degrades to a prompt-less launch (fail-safe direction)', () => {
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'bogus', terminal: true })).toBe(
|
||||
`launch opencode ${dir} --model deepseek/deepseek-v4-flash --auto`,
|
||||
`launch opencode ${dir} --model cheapestinference/deepseek-v4-flash --auto`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||