feat(C141): perfis de permissão por assessor — Visão × Edição configuráveis, enforcement fail-closed (Closes #105)

(cherry picked from commit 60857ad34d)
This commit is contained in:
Francisco Solla 2026-08-19 20:02:51 -03:00
parent be47027112
commit 3f74b696e1
41 changed files with 15905 additions and 101 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**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).

View file

@ -0,0 +1 @@
**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).

View file

@ -0,0 +1,129 @@
# 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

View file

@ -6,6 +6,7 @@ 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'
@ -18,6 +19,7 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
sendAdvisorPasswordResetFormAction,
updateAdvisorContactFormAction,
updateAdvisorPermissionFormAction,
updateAdvisorProfileFormAction,
} from '../formActions'
@ -89,6 +91,27 @@ 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"

View file

@ -5,10 +5,13 @@ import {
sendAdvisorPasswordReset,
setAdvisorMunicipalitiesBatch,
updateAdvisorContactFicha,
updateAdvisorPermission,
updateAdvisorProfile,
} from '@/app/(campaign)/campanha/actions/advisor'
import { isAdvisorEditingValue, isAdvisorVisibilityValue } from '@/lib/campaignAdvisorProfile'
import {
nullableFormText,
optionalFormText,
repeatedPhoneFormValues,
repeatedRelationshipFormValues,
requiredFormBoolean,
@ -116,10 +119,21 @@ 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')
@ -136,3 +150,30 @@ 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.',
})

View file

@ -27,6 +27,7 @@ import {
createAdvisorFormAction,
sendAdvisorPasswordResetFormAction,
setAdvisorMunicipalitiesFormAction,
updateAdvisorPermissionFormAction,
updateAdvisorProfileFormAction,
} from './formActions'
@ -82,6 +83,7 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps)
municipalitiesAction={setAdvisorMunicipalitiesFormAction}
createAction={createAdvisorFormAction}
passwordResetAction={sendAdvisorPasswordResetFormAction}
updatePermissionAction={updateAdvisorPermissionFormAction}
autoCreateDraft={autoCreateDraft}
/>

View file

@ -4,6 +4,7 @@ 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,
@ -31,7 +32,11 @@ import {
import { mapActivityOverlayError } from '@/utilities/activityOverlayErrors'
import { loadActivityAgendaEventsData } from '@/utilities/activityPageData'
import { activityFormSelect, toActivityFormViewModel } from '@/utilities/activityViewModels'
import { canCampaignUserRescheduleActivity, isCampaignStaff } from '@/utilities/campaignAccess'
import {
canCampaignUserRescheduleActivity,
getWritableMunicipalityIds,
isCampaignStaff,
} from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
@ -114,6 +119,17 @@ 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),
@ -190,6 +206,15 @@ 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.

View file

@ -16,11 +16,13 @@ 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'
@ -105,6 +107,8 @@ 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,
@ -266,6 +270,38 @@ 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,

View file

@ -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> => {
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,
await assertPersonContactWritable({
payload,
actor,
contactID,
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 (

View file

@ -33,7 +33,7 @@ import {
type MunicipalityLeadershipCreateInput,
} from '@/lib/schemas/leadership'
import type { CampaignUser, Contact, Leadership } from '@/payload-types'
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
import { getWritableMunicipalityIds } from '@/utilities/campaignAccess'
import {
getCampaignActionContext,
reloadStaffActor,
@ -54,7 +54,14 @@ const getFreshStaffActor = (
req?: PayloadTransactionRequest,
): Promise<CampaignUser> => reloadStaffActor(payload, actor, LEADERSHIP_STAFF_MESSAGE, req)
/** Advisors may only link leaderships to municipalities they administer. */
/**
* 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).
*/
const assertMunicipalitiesWithinScope = async (
payload: Payload,
actor: CampaignUser,
@ -63,8 +70,10 @@ const assertMunicipalitiesWithinScope = async (
) => {
if (actor.role !== 'advisor') return
const administered = new Set(await getAdvisorMunicipalityIds(payload, actor.id, req))
const outside = municipalityIDs.filter((id) => !administered.has(id))
const writable = await getWritableMunicipalityIds(payload, actor, req)
if (writable === null) return
const outside = municipalityIDs.filter((id) => !writable.includes(id))
if (outside.length > 0) {
throw new Error(LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE)
}

View file

@ -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, isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
import {
getCampaignActionContext,
reloadStaffActor,
@ -63,6 +63,7 @@ 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'
@ -120,11 +121,11 @@ export const deletePersonAction = async (input: unknown) => {
// ---------------------------------------------------------------------------
/**
* 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).
* 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).
*/
const assertPersonContactEditable = async (
payload: Payload,
@ -132,34 +133,13 @@ const assertPersonContactEditable = async (
contactID: number,
req: { transactionID: number | string },
): Promise<void> => {
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)
}
await assertPersonContactWritable({
payload,
actor,
contactID,
req,
errorMessage: PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
})
}
export const updatePersonContactRecord = async (

View file

@ -1,7 +1,15 @@
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 { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
import {
ADVISOR_PROFILE_COHERENCE_MESSAGE,
isPlanilhaPlaceholderEmail,
} from '@/lib/schemas/advisor'
import type { CampaignUser as CampaignUserDocument } from '@/payload-types'
import {
canCreateCampaignUserPhone,
@ -22,6 +30,7 @@ import {
type CollectionAfterReadHook,
type CollectionBeforeChangeHook,
type CollectionBeforeDeleteHook,
type CollectionBeforeValidateHook,
type CollectionConfig,
type Payload,
type PayloadRequest,
@ -117,12 +126,45 @@ const preventSelfServicePrivilegedFields: CollectionBeforeChangeHook<
if (String(req.user.id) !== String(originalDoc.id)) return data
for (const field of ['role', 'name', 'email', 'username', 'phone', 'contact'] as const) {
for (const field of [
'role',
'name',
'email',
'username',
'phone',
'contact',
'visibility',
'editing',
] 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
@ -383,6 +425,7 @@ export const CampaignUser: CollectionConfig = {
delete: canManageCampaignUsers,
},
hooks: {
beforeValidate: [enforceCoherentAdvisorProfile],
beforeChange: [
preventAssignedAdvisorDowngrade,
preventSelfServicePrivilegedFields,
@ -441,6 +484,36 @@ 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',

View file

@ -0,0 +1,67 @@
'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>
)
}

View file

@ -0,0 +1,170 @@
'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>
)
}

View file

@ -16,6 +16,7 @@ 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,
@ -34,6 +35,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/Table'
import type { AdvisorEditing, AdvisorVisibility } from '@/lib/campaignAdvisorProfile'
import {
resolveVisibleColumns,
toCampaignColumnPickerColumns,
@ -56,6 +58,8 @@ 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
@ -78,6 +82,7 @@ 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',
@ -111,6 +116,10 @@ type AdvisorsTableProps = {
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
updatePermissionAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
autoCreateDraft?: boolean
}
@ -119,6 +128,8 @@ const emptyDraft = (): DraftAdvisor => ({
email: '',
phone: '',
municipalityIds: [],
visibility: 'carteira',
editing: 'carteira',
})
const displayEmail = (email: string | null): string | null => {
@ -135,6 +146,7 @@ export const AdvisorsTable = ({
municipalitiesAction,
createAction,
passwordResetAction,
updatePermissionAction,
autoCreateDraft = false,
}: AdvisorsTableProps) => {
const router = useRouter()
@ -192,6 +204,8 @@ 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))
@ -333,6 +347,18 @@ 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
@ -443,6 +469,15 @@ 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 ? (

View file

@ -0,0 +1,88 @@
/**
* 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)

View file

@ -27,6 +27,8 @@ 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.).

View file

@ -1,5 +1,10 @@
import { z } from 'zod'
import {
ADVISOR_EDITING_VALUES,
ADVISOR_VISIBILITY_VALUES,
isCoherentAdvisorProfile,
} from '@/lib/campaignAdvisorProfile'
import {
MAX_MUNICIPALITIES_PER_BATCH,
MUNICIPALITY_ADVISORS_CAP_MESSAGE,
@ -66,6 +71,8 @@ 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,
@ -73,14 +80,37 @@ 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,
@ -112,3 +142,4 @@ 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>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
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";`)
}

View file

@ -57,6 +57,7 @@ import * as migration_20260811_234822_add_state_deputy_ballot_name from './20260
import * as migration_20260818_111748_add_social_feed_settings from './20260818_111748_add_social_feed_settings';
import * as migration_20260818_192948_add_instagram_social_feed_fields from './20260818_192948_add_instagram_social_feed_fields';
import * as migration_20260819_192605_add_campaign_newsletter_capture from './20260819_192605_add_campaign_newsletter_capture';
import * as migration_20260819_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';
@ -356,6 +357,11 @@ 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_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',
},
{
up: migration_20260819_233503_add_campaign_demand_responsibles.up,
down: migration_20260819_233503_add_campaign_demand_responsibles.down,

View file

@ -273,6 +273,14 @@ 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;
@ -1542,6 +1550,8 @@ export interface CampaignUserSelect<T extends boolean = true> {
name?: T;
avatar?: T;
role?: T;
visibility?: T;
editing?: T;
phone?: T;
updatedAt?: T;
createdAt?: T;

View file

@ -2,11 +2,12 @@
// Activities
// ---------------------------------------------------------------------------
import type { Access, FieldAccess, Where } from 'payload'
import type { Access, FieldAccess, PayloadRequest, Where } from 'payload'
import type { CampaignUser } from '@/payload-types'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
advisorEditingAccess,
advisorMunicipalityScopeWhere,
getFreshCampaignUser,
isCampaignLeader,
@ -21,13 +22,47 @@ const canStaffCreateActivity: FieldAccess = async ({ req }) => {
return isCampaignStaff(await getFreshCampaignUser(req))
}
export const canCreateActivity: Access = canStaffCreateActivity
/**
* 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 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
@ -37,27 +72,34 @@ export const canReadActivity: Access = async ({ req }): Promise<boolean | Where>
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser.role === 'advisor') {
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),
],
}
// C141 — Visão "Tudo": the advisor sees the whole activity agenda.
if (currentUser.visibility === 'tudo') return true
return advisorActivityScopeWhere(req, currentUser)
}
return false
}
/** Same row scope as read — leader write limits are enforced in `beforeChange`. */
export const canUpdateActivity: Access = canReadActivity
/**
* 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)
}
export const canDeleteActivity: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -1,5 +1,44 @@
// ---------------------------------------------------------------------------
// 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
}

View file

@ -30,7 +30,11 @@ const getAccessibleContactIds = (
? {
and: [{ user: { equals: currentUser.id } }, { supportStatus: { equals: 'engajado' } }],
}
: advisorMunicipalityScopeWhere('municipalities', municipalityIDs)
: // 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)
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
const collections = req.payload.collections as Record<string, unknown>

View file

@ -7,6 +7,7 @@ import type { Access, Where } from 'payload'
import { relationshipId } from '@/lib/relationship'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
advisorEditingAccess,
getFreshCampaignUser,
isCampaignLeader,
isCampaignUnrestricted,
@ -24,6 +25,9 @@ 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
@ -49,9 +53,18 @@ export const canReadCampaignDemand: Access = async ({ req }): Promise<boolean |
}
/**
* Staff manage demands they can see — same row scope as read, which is also
* who may manage the `responsibles` list (C143).
* 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.
*/
export const canUpdateCampaignDemand: Access = canReadCampaignDemand
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 canDeleteCampaignDemand: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -8,12 +8,13 @@ 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,
resolveActorScopedRead,
resolveProfileScopedRead,
} from '@/utilities/access/shared'
type LeadershipID = number
@ -28,7 +29,7 @@ const municipalityIDsFromData = (value: unknown): number[] =>
(Array.isArray(value) ? value : []).map(relationshipId).filter((id): id is number => id !== null)
export const canReadLeadership: Access = ({ req }) =>
resolveActorScopedRead(req, 'municipalities', getAccessibleMunicipalityIds)
resolveProfileScopedRead(req, 'municipalities', getAccessibleMunicipalityIds)
export const canCreateLeadership: Access = async ({ data, req }) => {
if (isPayloadAdmin(req.user)) return true
@ -37,8 +38,12 @@ 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
@ -53,6 +58,10 @@ 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),
@ -62,9 +71,10 @@ export const canManageLeadership: Access = async ({ req }) => {
export const canDeleteLeadership: Access = ({ req }) => isPayloadAdmin(req.user)
/**
* 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.
* 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.
*/
export const getAccessibleLeadershipIds = (
req: PayloadRequest,
@ -73,9 +83,11 @@ 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[] = []
let ids: LeadershipID[] | null = []
if (currentUser.role === 'advisor' && collections.leadership) {
if (currentUser.visibility === 'tudo') return null
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
const result = await find({
collection: 'leadership',
@ -93,5 +105,5 @@ export const getAccessibleLeadershipIds = (
.filter((id): id is number => id !== null)
}
return [...new Set(ids)]
return [...new Set(ids ?? [])]
})

View file

@ -5,12 +5,14 @@
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,
@ -158,6 +160,32 @@ 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)
@ -168,6 +196,9 @@ 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
@ -185,7 +216,10 @@ export const canUpdateMunicipality: Access = async ({ req }) => {
const currentUser = await getFreshCampaignUser(req)
if (!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 {
advisors: {

View file

@ -7,11 +7,12 @@ import type { Access, FieldAccess } 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 canCreateMunicipalityUpdate: Access = async ({ data, req }) => {
@ -21,15 +22,19 @@ 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 }) =>
resolveActorScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
resolveProfileScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
export const canMutateMunicipalityUpdate: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -5,6 +5,7 @@
import type { Access } from 'payload'
import {
advisorEditingAccess,
getFreshCampaignUser,
isCampaignLeader,
isCampaignStaff,
@ -14,7 +15,13 @@ import {
export const canCreateOrganization: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
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
}
export const canReadOrganization: Access = async ({ req }) => {
@ -30,7 +37,12 @@ export const canReadOrganization: Access = async ({ req }) => {
export const canManageOrganization: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
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
}
export const canDeleteOrganization: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -197,17 +197,59 @@ 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.
* 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).
*/
export const resolveAccessibleIds = async <ID>(
req: PayloadRequest,
user: CampaignActor = req.user,
memoKey: string,
compute: (currentUser: CampaignUser) => Promise<ID[]>,
compute: (currentUser: CampaignUser) => Promise<ID[] | null>,
): Promise<ID[] | null> => {
const currentUser =
isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user

View file

@ -4,12 +4,23 @@
import type { Access } from 'payload'
import { getFreshCampaignUser, isCampaignStaff, isPayloadAdmin } from '@/utilities/access/shared'
import {
advisorEditingAccess,
getFreshCampaignUser,
isCampaignStaff,
isPayloadAdmin,
} from '@/utilities/access/shared'
export const canCreateStateDeputy: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
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
}
export const canReadStateDeputy: Access = async ({ req }) => {
@ -18,6 +29,15 @@ export const canReadStateDeputy: Access = async ({ req }) => {
return isCampaignStaff(await getFreshCampaignUser(req))
}
export const canManageStateDeputy: Access = canReadStateDeputy
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 canDeleteStateDeputy: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -7,6 +7,7 @@ import type { Access, Where } from 'payload'
import { relationshipId } from '@/lib/relationship'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
advisorEditingAccess,
advisorMunicipalityScopeWhere,
getFreshCampaignUser,
isCampaignLeader,
@ -27,6 +28,11 @@ 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
}
@ -59,6 +65,35 @@ export const canReadSupporter: Access = async ({ req }): Promise<boolean | Where
return false
}
export const canManageSupporter: Access = canReadSupporter
/**
* 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 canDeleteSupporter: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -7,11 +7,13 @@ 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 }) => {
@ -27,6 +29,10 @@ 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
}
@ -35,9 +41,24 @@ export const canCreateVotePledge: Access = async ({ data, req }) => {
}
export const canReadVotePledge: Access = ({ req }) =>
resolveActorScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
resolveProfileScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
/** Same row scope as read — the estimated fields are gated by field access. */
export const canUpdateVotePledge: Access = canReadVotePledge
/**
* 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)
}
export const canDeleteVotePledge: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -2,6 +2,7 @@ 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'
@ -34,6 +35,9 @@ 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. */
@ -43,6 +47,9 @@ 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
@ -197,7 +204,7 @@ export const loadAdvisorListPageData = async (
limit: advisorPageSize,
page: state.page,
sort: 'name',
select: { name: true, email: true, phone: true, role: true },
select: { name: true, email: true, phone: true, role: true, visibility: true, editing: true },
overrideAccess: true,
})
@ -211,6 +218,8 @@ 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,
@ -230,14 +239,24 @@ 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 },
select: {
name: true,
email: true,
phone: true,
role: true,
contact: true,
visibility: true,
editing: true,
},
// Intentional admin bypass — same rationale as `loadAdvisorListPageData`.
overrideAccess: true,
})
} catch {
@ -271,5 +290,7 @@ export const loadAdvisorDetail = async (
contactID,
fichaPhones,
municipalities: municipalitiesByAdvisor.get(advisor.id) ?? [],
visibility: advisor.visibility ?? 'carteira',
editing: advisor.editing ?? 'carteira',
}
}

View file

@ -6,6 +6,7 @@
*/
export {
advisorEditingAccess,
canManagePublishedContent,
eligibleCampaignStaffWhere,
hasPayloadPanelAccess,
@ -43,6 +44,7 @@ export {
getAccessibleMunicipalityIds,
getAdvisorMunicipalityIds,
getEngagedLeaderMunicipalityIds,
getWritableMunicipalityIds,
} from '@/utilities/access/municipalities'
export { canManageContacts, canReadContacts } from '@/utilities/access/contacts'

View file

@ -0,0 +1,85 @@
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)
}
}

View file

@ -50,7 +50,10 @@ type CampaignCollection =
type OwnedIDs = Record<CampaignCollection, Set<number>>
type CampaignUserInput = Partial<
Pick<CampaignUser, 'name' | 'role' | 'phone' | 'email' | 'username' | 'password'>
Pick<
CampaignUser,
'name' | 'role' | 'phone' | 'email' | 'username' | 'password' | 'visibility' | 'editing'
>
>
type AdminUserInput = {
email?: string

View file

@ -0,0 +1,774 @@
// @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/)
})
})

View file

@ -5,9 +5,11 @@ 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'
@ -81,4 +83,48 @@ 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] } })
})
})

View file

@ -0,0 +1,50 @@
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)
})
})