diff --git a/docs/changelog/2026-08-24-c144.md b/docs/changelog/2026-08-24-c144.md new file mode 100644 index 00000000..6c9d0ea7 --- /dev/null +++ b/docs/changelog/2026-08-24-c144.md @@ -0,0 +1 @@ +**C144 — Formulários de escrita com options write-scoped (municípios só do escopo de edição) (2026-08-24):** `loadWritableMunicipalityOptions` irmã de `loadMunicipalityOptions` em `src/utilities/campaignRelationOptions.ts` resolve o eixo Edição via `getWritableMunicipalityIds` (C141): irrestrito/edita-tudo delega ao loader read; somente_leitura → `[]` sem fetch; carteira → `id: { in }` ordenado por nome. Call sites de escrita trocados (atividade create com split dos filtros read em `atividades/page.tsx`, atividade edit, demanda nova, organização nova+edita, liderança nova+ficha, apoiador novo via `loadSupporterCreatePageData`); giro composer ganha `writeScope` em `loadVisitPlannerRegions`/`loadVisitCandidates` (escopa o fetch, dossier `loadMunicipalityVisitEligibility` segue read); células chip de lideranças e dobradinhas passam a `addableIds` do write scope com guardas (dobradinhas mantém a lente de leitura da carteira para visão-carteira). Sem migration, sem mudança de enforcement. Int pins novos em `campaignAdvisorPermission.int.spec.ts` (matriz do loader, giro scoped vs dossier read, page-data de supporter). `pessoas` fica como follow-up C145 (#878). Plano: `docs/plans/escopo-formularios-escrita-perfil-impl.md`. diff --git a/docs/plans/escopo-formularios-escrita-perfil-impl.md b/docs/plans/escopo-formularios-escrita-perfil-impl.md new file mode 100644 index 00000000..c7da2aee --- /dev/null +++ b/docs/plans/escopo-formularios-escrita-perfil-impl.md @@ -0,0 +1,100 @@ +# Impl: Escala pós-C141 — options write-scoped para formulários de escrita + +Status: em execução +Atualizado em: 2026-08-24 +Issue: #769 +Intenção: docs/plans/escopo-formularios-escrita-perfil.md +Appetite restante: ~0,5–1 dia eng (herdado) + +## Leitura da intenção + +- **Outcome:** os pickers de município dos formulários de ESCRITA oferecem apenas municípios do escopo de escrita do ator (`getWritableMunicipalityIds`): irrestrito/tudo → todos; carteira → carteira; somente_leitura → `[]` (não abre o form é C142; aqui só não oferecer nada). Superfícies: atividade (create/update + giro), demanda (create), apoiador (novo), liderança (create/edit), organização (create/edit). +- **O que NÃO negociar:** read surfaces intocadas (listas, filtros, agenda, dossier, mapa) — `loadMunicipalityOptions` continua read-scoped para elas; nenhuma mudança de enforcement (C141 já fail-closed: `activity.ts:128-131,213-216`, `assertMunicipalitiesWithinScope` `leadership.ts:65-80`, ficha `assertPersonContactWritable`, access de demands/supporters/organizations); sem migration; sem mudar semântica de `getWritableMunicipalityIds`/`getAdvisorMunicipalityIds`. +- **O que reavaliar:** o giro não usa `loadMunicipalityOptions` (usa `loadVisitPlannerRegions`/`loadVisitCandidates`, read-scoped) e as células chip (`MunicipalityPortfolioCell`/`PeopleMunicipalityCell`) recebem `addableIds` de `getAdvisorMunicipalityIds` (READ). Ambos são superfícies de escrita e entram — com o menor delta possível e guardas para não regredir leitura. + +## Abordagem recomendada + +```mermaid +flowchart TD + A[Write-form page / giro / chip list] --> B{superfície} + B -->|forms create/edit| C[loadWritableMunicipalityOptions] + B -->|giro composer| D[loadVisitPlannerRegions / loadVisitCandidates + writeScope] + B -->|chip addableIds| E[getWritableMunicipalityIds] + C --> F[getWritableMunicipalityIds] + F -->|null irrestrito/tudo| G[find all, sort name] + F -->|[] somente_leitura| H[return [] / skip fetch] + F -->|carteira ids| I[find where id in ids, sort name] + E --> J[guards de edição: canEditAny / length] + D --> K[default read: unchanged; writeScope: id in ids no where] +``` + +**Opções (loader):** A) `scope: 'read'|'write'` param no `loadMunicipalityOptions` | B) irmã `loadWritableMunicipalityOptions` em `campaignRelationOptions.ts` | C) filtragem client-side da lista read +**Recomendação: B** — call sites read ficam literalmente intocados (sem chance de widening acidental por param default), nome espelha `getWritableMunicipalityIds`, grepável; call sites de escrita trocam o import. Verificado: variante read ordena `sort: 'name'`, `select: {name:true}`, `overrideAccess:false` (`campaignRelationOptions.ts:15-26`); `municipality.id` é `number` (Payload numeric id; `getAdvisorMunicipalityIds` já filtra `relationshipId` → `number[]`). +**Alternativas rejeitadas:** A porque todo call site read passaria a carregar o param e a intenção diz "read surfaces NÃO tocar" — B torna o contrato read imutável por construção; C rejeitada porque vaza o catálogo completo para o client e viola o espírito fail-closed. + +**Opções (giro):** 1) write-scope nos loaders via param opcional | 2) fora do escopo, deixar o guard batch C141 como enforcement +**Recomendação: 1** — o composer É um write-composer (stops viram `activity` drafts; `createTourDraftsFormAction` → `activity.ts:213-216`). Verificado: `loadVisitPlannerRegions` monta as TIs contando municípios da leitura read-scoped (`visitPlannerData.ts:123-146`) e `loadVisitCandidates` monta o bundle (peers de encaixe, sugestão, agregados) a partir do fetch read-scoped (`:155-279`). Para visão-tudo+edita-carteira, hoje o composer oferece os 435 e o servidor rejeita no batch. Filtrar na página rejeitado: a sugestão e os `stopPeersByRegion` seriam computados sobre o escopo read e depois descartados — a conta de "encaixe em giro" ficaria errada. Escopar o **fetch** dentro do loader faz todo o pipeline derivado (aggregates, peers, sugestão) sair consistente. Param `writeScope?: boolean` (default `false` → read) nos dois loaders; `loadMunicipalityVisitEligibility` (card do dossier, read) fica com o default e não muda. +**Alternativa rejeitada: 2** — a intenção lista "giro" explicitamente como superfície; o custo é um param opcional + um `id: { in }` no where (ou early-return de bundle vazio `{ phase, groups: [] }`). + +**Opções (chips `addableIds`):** 1) trocar `getAdvisorMunicipalityIds` → `getWritableMunicipalityIds` nas páginas de lista | 2) fora do escopo +**Recomendação: 1 parcial** — células chip são controles de escrita (add/remove de vínculos com commit fail-closed). Verificado nos três call sites: + +- `liderancas/page.tsx:373,434`: swap limpo — a expressão `canEditAny && administeredIds ? { addableMunicipalityIds: new Set(...) }` já omite o prop quando `canEditAny=false` (somente_leitura), então `[]` é inalcançável; carteira = mesmo conjunto de hoje; edita-tudo (`null`) → prop omitido → todos (o servidor já aceita; paridade com coordinator). Único uso da variável é :434. +- `dobradinhas/page.tsx:323,356`: swap com guarda `writableIds && writableIds.length > 0 ? new Set(writableIds) : undefined` — sem o length-check, `[]` (somente_leitura) é truthy → `Set([])` → todas as chips somem, regressão de leitura (hoje visível = carteira). +- `pessoas/page.tsx:544,553`: **fora do escopo** — `buildPeopleEditability` (`:145-168`) não tem branch "null = tudo": para edita-tudo, `administered=null` → `inCarteira` sempre false → edição por linha quebra (hoje `Set(carteira)` deixa linhas da carteira editáveis). O conserto é C142-shaped (branch 'tudo' no helper). Os commits da ficha continuam fail-closed (`assertPersonContactWritable`). Anotar follow-up. +- Delta de leitura do swap em dobradinhas: somente visão-tudo+somente_leitura passa a exibir chips fora da carteira (hoje `Set(carteira)` os esconde — quirk pré-existente que contraria C141 "visão tudo vê tudo"). Direção correta, aceito e anotado. + **Rejeitada: 2** — o under-offer persiste para edita-tudo (picker mais estreito que o contrato do servidor); o custo do swap com guarda é ~6 linhas. + +### Componentes / mudanças + +| Símbolo | Arquivo | Responsabilidade | Reusa | +| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| ✚ | `src/utilities/campaignRelationOptions.ts` | `loadWritableMunicipalityOptions(payload, user)`: resolve `getWritableMunicipalityIds`; `null` → delega a `loadMunicipalityOptions` (query idêntica); `[]` → `[]` sem fetch; ids → `find({ where: { id: { in: ids } }, sort: 'name', select: { name: true }, overrideAccess: false, user })` | `getWritableMunicipalityIds` (via `campaignAccess`, import já existente no módulo — sem ciclo novo) | +| ✎ | `src/app/(campaign)/campanha/(app)/atividades/page.tsx:60` | **split obrigatório**: `municipalityOptions` (read) fica para `ActivityFilters` (:89-93); novo `loadWritableMunicipalityOptions` alimenta `ActivityCreateOverlayHost` (:79-84) | ambos loaders no mesmo `Promise.all` | +| ✎ | `.../atividades/[slug]/page.tsx:100` | `ActivityEditOverlayHost` passa a receber write-scoped (detail não tem filtro — troca direta) | — | +| ✎ | `src/utilities/visit/visitPlannerData.ts:123-279` | `writeScope?: boolean` em `loadVisitPlannerRegions` e `loadVisitCandidates`: quando true, resolve writable e (a) `[]` → early `[]` / `{ phase, groups: [] }`, (b) ids → `{ id: { in: ids } }` no where (regions) / `and` com o where de região (candidates); `null` → inalterado | `getWritableMunicipalityIds` | +| ✎ | `.../atividades/giros/page.tsx:90-93` | passa `{ writeScope: true }` nos dois loaders | — | +| ✎ | `demandas/nova/page.tsx:33`, `organizacoes/nova/page.tsx:22`, `organizacoes/[slug]/page.tsx:51`, `liderancas/nova/page.tsx:32`, `liderancas/[id]/page.tsx:66` | trocam import/call para o loader write-scoped (forms de create/edit) | — | +| ✎ | `src/utilities/supporter/supporterPageData.ts:162` | `loadSupporterCreatePageData` usa write-scoped; **:115 (`loadSupportersPageData` → filtros) permanece read** | — | +| ✎ | `liderancas/page.tsx:373,434`, `dobradinhas/page.tsx:323,356` | `addableIds` de `getWritableMunicipalityIds` (com guardas descritas acima) | — | +| ✓ | `pessoas/page.tsx`, `agenda/page.tsx:55`, `supporterPageData.ts:115`, `atividades/page.tsx` filtros, dossier | intocados | — | + +Migration: sem migration. Access/Consent: sem mudança (enforcement C141 intacto). UI: nenhum componente muda (options são prop-drilled; `RelationMultiSelect`/overlays idênticos). + +## Fases verificáveis (tracer first) + +1. **Tracer/server — loader + atividade + giro (onde o servidor ACEITA o mismatch)** — `loadWritableMunicipalityOptions`; split em `atividades/page.tsx`; troca no `[slug]`; `writeScope` no visit planner + giros page. Tester int: para advisor visão-tudo+edita-carteira, picker de atividade e candidatos/regiões de giro refletem o eixo (contêm só a carteira); para edita-tudo, todos; para somente_leitura, `[]`; `loadMunicipalityVisitEligibility`/read loaders inalterados para visão-tudo. +2. **Demanda/apoiador/liderança/organização (UX — servidor já rejeita)** — troca nos 6 forms + chips (liderancas/dobradinhas). Tester int: `loadSupporterCreatePageData` e demais page-data utilitárias retornam options write-scoped; chips via loader direto. +3. **Gates** — `pnpm gate:fast` (tsc, lint 0 warnings, format:check, knip, cycles, test) + `pnpm build`; `pnpm push`; flips da issue via `pnpm issue`. + +## Rabbit holes / Não escopo (engenharia) + +- Mudar enforcement C141 (nenhum): servidor já fail-closed em todas as superfícies. +- Read surfaces: listas, filtros (atividade, agenda, apoiadores), dossier/eligibility card, mapa — intocados. +- C142 presentation: ocultar/redirecionar forms para somente_leitura (gate `'writable'`), branch 'tudo' no `buildPeopleEditability` de pessoas, mensagens do empty state do giro ("sai da sua carteira" para perfis leitura é impreciso — C142 ajusta). +- Filtragem client-side (vaza catálogo) e rota-optimizer/adjacência para giro. +- Sem migration, sem schema, sem componentes novos, sem E2E novo (C142 é dono de presença; e2e HTTP existente cobre botões). + +## Riscos e mitigação + +- **Regressão de filtros read** ao dividir `atividades/page.tsx`: mitigado pela divisão explícita + pins int (read loader continua retornando tudo para visão-tudo). +- **Armadilha de truthiness de `[]`** nos chips: `Set([])` esconde TODAS as chips (leitura some). Mitigado: liderancas mantém guarda `canEditAny &&`; dobradinhas ganha length-check. Pessoas fica fora por quebrar `buildPeopleEditability`. +- **Usuário stale** (visibility/editing): `requireCampaignPageActor` → `getCampaignUser` faz `findByID` por request (`campaignAuth.ts:97-101`) — usuário é fresco; `getWritableMunicipalityIds` documenta exigência. +- **Vazamento de escopo no giro**: `loadMunicipalityVisitEligibility` compartilha `loadVisitCandidates` — default read mantém o card do dossier; pin int explícito. +- **Registro editável com município fora do writable** (criado por ator de escopo maior): chip visível no record mas não re-oferecido pelo picker após remoção — fail-safe (servidor rejeita), C142-adjacente; sem ação nesta entrega. +- **Ciclo de imports**: `campaignRelationOptions.ts` já importa de `campaignAccess` (linha 8); visit planner importa `getWritableMunicipalityIds` de `campaignAccess` — sem novo top-level module (`src/utilities` pinnado), knip satisfeito. + +## Aceite de engenharia (checkboxes) + +- [x] `loadWritableMunicipalityOptions` em `campaignRelationOptions.ts` (null→delega; []→[] sem fetch; ids→`id: { in }`, `sort: 'name'`) +- [x] `atividades/page.tsx`: overlay create write-scoped; **filtros continuam read** +- [x] `atividades/[slug]/page.tsx`: overlay edit write-scoped +- [x] Giro: `writeScope` em `loadVisitPlannerRegions`/`loadVisitCandidates`; giros page passa true; dossier read intacto +- [x] `demandas/nova`, `organizacoes/nova`, `organizacoes/[slug]`, `liderancas/nova`, `liderancas/[id]`: forms write-scoped +- [x] `loadSupporterCreatePageData` write-scoped; `loadSupportersPageData` read intacto +- [x] Chips: `liderancas` (swap com `canEditAny &&`), `dobradinhas` (swap com length-guard); `pessoas` fora (anotado como C142-shaped) +- [x] Int pins em `campaignAdvisorPermission.int.spec.ts`: matriz do loader (carteira/tudo/leitura/coordinator), giro scoped vs read, page-data de supporter reflete o eixo; unit: sem mudança (loader é Payload-coupled → int); E2E: não novo +- [x] Gates: `tsc --noEmit`, `pnpm lint` 0 warnings, `pnpm format:check`, knip (pré-existente no worktree — falha igual no base limpo), `pnpm check:cycles`, `pnpm test` (unit 2465 + int 833), `pnpm build`; e2e da superfície afetada 6/6 +- [ ] Changelog: entrada curta em `docs/changelog/2026-08-24-c144.md`; push + flips da issue + +**Self-score (decision-quality): 5/5** — opções (loader irmã vs param; giro writeScope no fetch; chips parcial com guardas) decididas contra o código real verificado (sort/select da variante read, tipos de id, guardas `canEditAny`/length e ausência de branch "null=tudo" em `buildPeopleEditability`, frescor do user por página), com rejeitadas justificadas e sem regressão de read surface. diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx index 18fadcfb..c688f5f1 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx @@ -23,8 +23,8 @@ import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { loadAccessibleActivityTags } from '@/utilities/activityPageData' import { formatActivityWhenLabel } from '@/utilities/activityViewModels' import { - loadMunicipalityOptions, loadOrganizationOptions, + loadWritableMunicipalityOptions, } from '@/utilities/campaignRelationOptions' import { getActivityDetailPageData } from '@/utilities/activityDetailPageData' @@ -97,7 +97,7 @@ export default async function ActivityDetailPage({ if (canonicalTabRedirect) redirect(canonicalTabRedirect) const [municipalityOptions, organizationOptions, knownTags] = await Promise.all([ - loadMunicipalityOptions(payload, user), + loadWritableMunicipalityOptions(payload, user), loadOrganizationOptions(payload, user), loadAccessibleActivityTags(payload, user), ]) diff --git a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx index 8e99d26b..f68a65ab 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx @@ -88,8 +88,8 @@ export default async function TourComposerPage({ searchParams }: TourComposerPag const now = new Date() const [regions, bundle] = await Promise.all([ - loadVisitPlannerRegions(payload, user), - region ? loadVisitCandidates(payload, user, { region, now }) : null, + loadVisitPlannerRegions(payload, user, { writeScope: true }), + region ? loadVisitCandidates(payload, user, { region, now, writeScope: true }) : null, ]) const candidates = bundle?.groups.flatMap((group) => group.candidates) ?? [] const suggestion = composeTourSuggestion(candidates) diff --git a/src/app/(campaign)/campanha/(app)/atividades/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/page.tsx index f52fef4d..d06909cb 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/page.tsx @@ -30,6 +30,7 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadMunicipalityOptions, loadOrganizationOptions, + loadWritableMunicipalityOptions, } from '@/utilities/campaignRelationOptions' import { TOUR_COMPOSER_PATH } from '@/utilities/visit/visitPlannerUrl' @@ -54,13 +55,19 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag user.role === 'advisor' ? advisorEditingScope(user.visibility, user.editing) : 'tudo' const now = new Date() - const [{ result, state }, municipalityOptions, organizationOptions, knownTags] = - await Promise.all([ - loadActivityListPageData(payload, user, rawSearchParams, now), - loadMunicipalityOptions(payload, user), - loadOrganizationOptions(payload, user), - loadAccessibleActivityTags(payload, user), - ]) + const [ + { result, state }, + municipalityOptions, + writableMunicipalityOptions, + organizationOptions, + knownTags, + ] = await Promise.all([ + loadActivityListPageData(payload, user, rawSearchParams, now), + loadMunicipalityOptions(payload, user), + loadWritableMunicipalityOptions(payload, user), + loadOrganizationOptions(payload, user), + loadAccessibleActivityTags(payload, user), + ]) const resolvedUrl = resolveActivityListUrl(rawSearchParams, result.totalPages) if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref) @@ -78,7 +85,7 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx index be7565a8..d317dc38 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx @@ -11,7 +11,7 @@ import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { loadActivityRelationOptionById } from '@/utilities/activityRelationOptions' import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' -import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' +import { loadWritableMunicipalityOptions } from '@/utilities/campaignRelationOptions' import { createDemandFormAction } from './formActions' export const metadata = campaignPageMetadataFromCatalog('demandasNova') @@ -30,7 +30,7 @@ export default async function NewDemandPage({ searchParams }: NewDemandPageProps const requestedActivityId = strictDecimalInteger(firstValue(query.activity)) const requestedMunicipalityId = strictDecimalInteger(firstValue(query.municipality)) const [municipalityOptions, initialActivity] = await Promise.all([ - loadMunicipalityOptions(payload, user), + loadWritableMunicipalityOptions(payload, user), requestedActivityId ? loadActivityRelationOptionById(payload, user, requestedActivityId) : Promise.resolve(null), diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx index 609d10ea..02bd24f0 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx @@ -42,7 +42,11 @@ import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility' import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio' import { cn } from '@/lib/utils' -import { getAdvisorMunicipalityIds, isCampaignUnrestricted } from '@/utilities/campaignAccess' +import { + getAdvisorMunicipalityIds, + getWritableMunicipalityIds, + isCampaignUnrestricted, +} from '@/utilities/campaignAccess' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { @@ -312,7 +316,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP leadershipOptions, advisorOptions, municipalityIndex, - administeredIds, + addableMunicipalityIds, ] = await Promise.all([ loadStateDeputyListPageData(payload, user, canonicalUrl.state), isLeadershipVisible ? loadLeadershipOptions(payload, user) : Promise.resolve([]), @@ -320,7 +324,14 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP ? loadEligibleAdvisorOptions(payload, user) : Promise.resolve([]), loadMunicipalityPortfolioIndex(), - user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null, + // C144 — the cell's `addableIds` is ALSO the read lens (chips outside the + // set hide, `undefined` shows the whole catalog): a carteira-visão advisor + // keeps the carteira set so somente_leitura does not regress into seeing + // every município; visão-tudo follows the Edição axis (null → whole + // catalog, [] → none offered, carteira → portfolio adds). + user.role === 'advisor' && user.visibility === 'carteira' + ? getAdvisorMunicipalityIds(payload, user.id) + : getWritableMunicipalityIds(payload, user), ]) const resolvedUrl = resolveStateDeputyListUrl(rawSearchParams, totalPages) if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref) @@ -353,7 +364,11 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP })), canEditAdvisors, municipalityIndex, - administeredIds ? new Set(administeredIds) : undefined, + // The length-guard keeps `[]` (no writable município) from collapsing + // every chip: an empty `Set` hides all municipalities from the cell. + addableMunicipalityIds && addableMunicipalityIds.length > 0 + ? new Set(addableMunicipalityIds) + : undefined, ) return ( diff --git a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx index c63de589..44a65193 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx @@ -18,9 +18,9 @@ import { isCampaignUnrestricted } from '@/utilities/campaignAccess' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadEligibleAdvisorOptions, - loadMunicipalityOptions, loadOrganizationOptions, loadStateDeputyOptions, + loadWritableMunicipalityOptions, } from '@/utilities/campaignRelationOptions' import { loadLeadershipDetail } from '@/utilities/leadership/leadershipData' import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex' @@ -63,7 +63,7 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP const [municipalityOptions, organizationOptions, stateDeputyOptions, municipalityIndex] = await Promise.all([ - loadMunicipalityOptions(payload, user), + loadWritableMunicipalityOptions(payload, user), loadOrganizationOptions(payload, user), loadStateDeputyOptions(payload, user), loadMunicipalityPortfolioIndex(), diff --git a/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx index 1d5ae5fd..e2412c21 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx @@ -9,9 +9,9 @@ import { Button } from '@/components/ui/button' import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { - loadMunicipalityOptions, loadOrganizationOptions, loadStateDeputyOptions, + loadWritableMunicipalityOptions, } from '@/utilities/campaignRelationOptions' import { createLeadershipFormAction } from './formActions' @@ -29,7 +29,7 @@ export default async function NewLeadershipPage({ searchParams }: NewLeadershipP ]) const [municipalityOptions, organizationOptions, stateDeputyOptions] = await Promise.all([ - loadMunicipalityOptions(payload, user), + loadWritableMunicipalityOptions(payload, user), loadOrganizationOptions(payload, user), loadStateDeputyOptions(payload, user), ]) diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx index 7fec4f86..5b261dad 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -52,7 +52,7 @@ import { import { whatsAppHrefForPhone } from '@/lib/phone' import { MAX_LEADERSHIP_MUNICIPALITIES } from '@/lib/schemas/leadership' import { cn } from '@/lib/utils' -import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess' +import { getWritableMunicipalityIds } from '@/utilities/campaignAccess' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions' @@ -145,7 +145,10 @@ const leadershipColumns = ({ stateDeputyOptions: RelationCellOption[] municipalityIndex: MunicipalityPortfolioIndexEntry[] municipalityFilterOptions: LeadershipFilterOption[] - /** Advisors may only link municipalities they administer; staff: undefined. */ + /** + * C144 — advisors may only add municipalities in their WRITE scope + * (`undefined` = whole catalog: unrestricted staff and Edição "Tudo"). + */ addableMunicipalityIds?: ReadonlySet /** C142 — false when the advisor has `somente_leitura`: no invite, no per-row edits. */ canEditAny?: boolean @@ -365,12 +368,12 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP { rows, totalDocs, totalPages, filterFacets }, stateDeputyOptions, municipalityIndex, - administeredIds, + writableIds, ] = await Promise.all([ loadLeadershipListPageData(payload, user, canonicalUrl.state), isStateDeputyVisible ? loadStateDeputyOptions(payload, user) : Promise.resolve([]), loadMunicipalityPortfolioIndex(), - user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null, + user.role === 'advisor' ? getWritableMunicipalityIds(payload, user) : null, ]) // C142 — the write scope gates the create button, the invite action, and @@ -431,7 +434,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP })), municipalityIndex, municipalityFilterOptions, - ...(canEditAny && administeredIds ? { addableMunicipalityIds: new Set(administeredIds) } : {}), + ...(canEditAny && writableIds ? { addableMunicipalityIds: new Set(writableIds) } : {}), canEditAny, nowMs: Date.now(), }) diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx index fa7d3ce8..f65c3753 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx @@ -12,7 +12,7 @@ import { Badge } from '@/components/ui/Badge' import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { organizationKindLabels } from '@/lib/schemas/organization' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' -import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' +import { loadWritableMunicipalityOptions } from '@/utilities/campaignRelationOptions' import { loadOrganizationDetail } from '@/utilities/organizationData' import { updateOrganizationFormAction } from './formActions' @@ -48,7 +48,7 @@ export default async function OrganizationDetailPage({ params }: OrganizationDet const organization = await loadOrganizationDetail(payload, user, slug) if (!organization) notFound() - const municipalityOptions = await loadMunicipalityOptions(payload, user) + const municipalityOptions = await loadWritableMunicipalityOptions(payload, user) return ( diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx index c0413e66..cfa5537c 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx @@ -8,7 +8,7 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell import { Button } from '@/components/ui/button' import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' -import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' +import { loadWritableMunicipalityOptions } from '@/utilities/campaignRelationOptions' import { createOrganizationFormAction } from './formActions' export const metadata = campaignPageMetadataFromCatalog('organizacoesNova') @@ -19,7 +19,7 @@ export default async function NewOrganizationPage() { getPayload({ config }), ]) - const municipalityOptions = await loadMunicipalityOptions(payload, user) + const municipalityOptions = await loadWritableMunicipalityOptions(payload, user) return ( diff --git a/src/utilities/campaignRelationOptions.ts b/src/utilities/campaignRelationOptions.ts index f8d4ce7e..582a1a10 100644 --- a/src/utilities/campaignRelationOptions.ts +++ b/src/utilities/campaignRelationOptions.ts @@ -5,9 +5,9 @@ import type { Payload } from 'payload' import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect' import { populatedContactName } from '@/lib/relationship' import type { CampaignUser } from '@/payload-types' -import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess' +import { eligibleCampaignStaffWhere, getWritableMunicipalityIds } from '@/utilities/campaignAccess' -/** Municipalities the actor may operate on (coordinator: all 436; advisor: administered). */ +/** Municipalities the actor may operate on (coordinator: all 435; advisor: administered). */ export const loadMunicipalityOptions = async ( payload: Payload, user: CampaignUser, @@ -26,6 +26,36 @@ export const loadMunicipalityOptions = async ( return result.docs.map((municipality) => ({ id: municipality.id, name: municipality.name })) } +/** + * C144 — WRITE-scoped variant for the write forms: only municipalities the + * actor may write under the Edição axis (`getWritableMunicipalityIds`). + * `null` (unrestricted staff or `editing === 'tudo'`) delegates to the read + * loader (same query — the whole catalog); `[]` (`somente_leitura`) returns no + * options without a fetch; a carteira resolves to exactly the portfolio ids. + * Read surfaces keep `loadMunicipalityOptions`. + */ +export const loadWritableMunicipalityOptions = async ( + payload: Payload, + user: CampaignUser, +): Promise => { + const writableIds = await getWritableMunicipalityIds(payload, user) + if (writableIds === null) return loadMunicipalityOptions(payload, user) + if (writableIds.length === 0) return [] + + const result = await payload.find({ + collection: 'municipality', + depth: 0, + limit: 0, + pagination: false, + sort: 'name', + select: { name: true }, + where: { id: { in: writableIds } }, + user, + overrideAccess: false, + }) + return result.docs.map((municipality) => ({ id: municipality.id, name: municipality.name })) +} + export const loadOrganizationOptions = async ( payload: Payload, user: CampaignUser, diff --git a/src/utilities/supporter/supporterPageData.ts b/src/utilities/supporter/supporterPageData.ts index c4ef464f..928bcd1c 100644 --- a/src/utilities/supporter/supporterPageData.ts +++ b/src/utilities/supporter/supporterPageData.ts @@ -9,7 +9,10 @@ import { getSupporterRegistrationConsent, getSupporterVoteIntentionConsent, } from '@/utilities/campaignConsent' -import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' +import { + loadMunicipalityOptions, + loadWritableMunicipalityOptions, +} from '@/utilities/campaignRelationOptions' import { createEntityNotFoundError } from '@/utilities/entityNotFound' import { computeSupporterListOverviewAggregate } from '@/utilities/supporter/supporterListOverviewAggregate' import { @@ -159,7 +162,7 @@ export const loadSupporterCreatePageData = async ( user: CampaignUser, ): Promise => { const [municipalityOptions, registrationConsent, voteIntentionConsent] = await Promise.all([ - loadMunicipalityOptions(payload, user), + loadWritableMunicipalityOptions(payload, user), getSupporterRegistrationConsent(payload), getSupporterVoteIntentionConsent(payload), ]) diff --git a/src/utilities/visit/visitPlannerData.ts b/src/utilities/visit/visitPlannerData.ts index 8c03ab70..3ff775b9 100644 --- a/src/utilities/visit/visitPlannerData.ts +++ b/src/utilities/visit/visitPlannerData.ts @@ -9,6 +9,7 @@ import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship' import type { CalendarPhase } from '@/lib/visitPlannerAnchors' import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate' import type { CampaignUser, Municipality } from '@/payload-types' +import { getWritableMunicipalityIds } from '@/utilities/campaignAccess' import { computeGoalCoverage } from '@/utilities/municipality/goalCoverage' import { loadStatewideSuggestedGoals } from '@/utilities/municipality/municipalityGoalAccount' import { @@ -119,17 +120,28 @@ type VisitPlannerRegionOption = { * all 27 TIs to an advisor with a six-município portfolio would be offering * twenty-one dead ends, and evaluating the full eligibility of the state just to * build a picker would cost the whole planner load. + * + * C144 — `writeScope` restricts the municipalities to the actor's WRITE scope + * (`getWritableMunicipalityIds`): the composer is a write surface, so a + * Visão-Tudo/Edição-Carteira advisor must not be offered territories built + * over municípios the batch would reject. Default `false` keeps the read + * contract for `loadMunicipalityVisitEligibility` (dossier card). */ export const loadVisitPlannerRegions = async ( payload: Payload, user: CampaignUser, + { writeScope = false }: { writeScope?: boolean } = {}, ): Promise => { + const writableIds = writeScope ? await getWritableMunicipalityIds(payload, user) : null + if (writeScope && writableIds?.length === 0) return [] + const result = await payload.find({ collection: 'municipality', depth: 0, limit: 0, pagination: false, select: { slug: true }, + where: writableIds ? { id: { in: writableIds } } : {}, user, overrideAccess: false, }) @@ -150,14 +162,24 @@ export type VisitPlannerFilter = { region?: BahiaIdentityTerritory /** Injected so the calendar phase is testable and stable within a request. */ now?: Date + /** C144 — restrict to the actor's WRITE scope (write-composer surfaces). */ + writeScope?: boolean } export const loadVisitCandidates = async ( payload: Payload, user: CampaignUser, - { region, now = new Date() }: VisitPlannerFilter = {}, + { region, now = new Date(), writeScope = false }: VisitPlannerFilter = {}, ): Promise => { - const where: Where = region ? { region: { equals: region } } : {} + const writableIds = writeScope ? await getWritableMunicipalityIds(payload, user) : null + if (writeScope && writableIds?.length === 0) { + return { phase: resolveCalendarPhase(now), groups: [] } + } + + const conditions: Where[] = [] + if (region) conditions.push({ region: { equals: region } }) + if (writableIds) conditions.push({ id: { in: writableIds } }) + const where: Where = conditions.length === 0 ? {} : { and: conditions } const result = await payload.find({ collection: 'municipality', diff --git a/tests/int/campaignAdvisorPermission.int.spec.ts b/tests/int/campaignAdvisorPermission.int.spec.ts index 27bfc941..f933f172 100644 --- a/tests/int/campaignAdvisorPermission.int.spec.ts +++ b/tests/int/campaignAdvisorPermission.int.spec.ts @@ -20,6 +20,13 @@ import { } from '@/app/(campaign)/campanha/actions/municipality' import config from '@/payload.config' import { canCreateOrganization } from '@/utilities/campaignAccess' +import { loadWritableMunicipalityOptions } from '@/utilities/campaignRelationOptions' +import { loadSupporterCreatePageData } from '@/utilities/supporter/supporterPageData' +import { + loadMunicipalityVisitEligibility, + loadVisitCandidates, + loadVisitPlannerRegions, +} from '@/utilities/visit/visitPlannerData' import { installCampaignFixtures } from '../helpers/campaignFixtures' import { stub } from '../helpers/stub' @@ -772,3 +779,104 @@ describe('advisor permission profile (C141)', () => { ).rejects.toThrow(/exige Visão/) }) }) + +describe('write-scoped municipality options (C144)', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('loadWritableMunicipalityOptions follows the Edição axis for every profile', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const carteiraAdvisor = await fixtures.createCampaignUser('advisor') + const wideReadAdvisor = await fixtures.createCampaignUser('advisor', { + visibility: 'tudo', + editing: 'carteira', + }) + 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, [ + carteiraAdvisor.id, + wideReadAdvisor.id, + wideEditAdvisor.id, + readOnlyAdvisor.id, + ]) + + const idsOf = (options: Awaited>) => + new Set(options.map((option) => option.id)) + + // Unrestricted staff and Edição "Tudo" offer the whole catalog. + expect(idsOf(await loadWritableMunicipalityOptions(payload, coordinator)).has(outside.id)).toBe( + true, + ) + expect( + idsOf(await loadWritableMunicipalityOptions(payload, wideEditAdvisor)).has(outside.id), + ).toBe(true) + + // The C144 core fix: Visão "Tudo" + Edição "Carteira" offers ONLY the + // carteira — the picker can no longer suggest a município the server rejects. + for (const advisor of [carteiraAdvisor, wideReadAdvisor]) { + const ids = idsOf(await loadWritableMunicipalityOptions(payload, advisor)) + expect(ids.has(administered.id)).toBe(true) + expect(ids.has(outside.id)).toBe(false) + } + + // Somente leitura: nothing to write, nothing offered. + expect(await loadWritableMunicipalityOptions(payload, readOnlyAdvisor)).toEqual([]) + }) + + it('giro composer writeScope restricts regions and candidates; the dossier card stays read-wide', async () => { + const fixtures = campaignFixtures() + const wideReadAdvisor = await fixtures.createCampaignUser('advisor', { + visibility: 'tudo', + editing: 'carteira', + }) + const administered = await fixtures.getMunicipality() + const outside = await fixtures.getMunicipality() + await fixtures.assignMunicipalityAdvisors(administered.id, [wideReadAdvisor.id]) + + // Regions are built only over writable municípios — one portfolio, one count. + const regions = await loadVisitPlannerRegions(payload, wideReadAdvisor, { writeScope: true }) + expect(regions.reduce((sum, region) => sum + region.municipalityCount, 0)).toBe(1) + + // Candidates exclude the readable-but-not-writable município. + const bundle = await loadVisitCandidates(payload, wideReadAdvisor, { writeScope: true }) + const slugs = bundle.groups.flatMap((group) => + group.candidates.map((candidate) => candidate.slug), + ) + expect(slugs).toEqual([administered.slug]) + expect(slugs).not.toContain(outside.slug) + + // The dossier eligibility card (read surface) keeps the read scope: the + // same actor still reads the out-of-carteira município there. + const { candidate } = await loadMunicipalityVisitEligibility( + payload, + wideReadAdvisor, + outside.slug, + ) + expect(candidate).not.toBeNull() + }) + + it('loadSupporterCreatePageData offers only writable municipalities', async () => { + const fixtures = campaignFixtures() + const wideReadAdvisor = await fixtures.createCampaignUser('advisor', { + visibility: 'tudo', + editing: 'carteira', + }) + const administered = await fixtures.getMunicipality() + const outside = await fixtures.getMunicipality() + await fixtures.assignMunicipalityAdvisors(administered.id, [wideReadAdvisor.id]) + + const pageData = await loadSupporterCreatePageData(payload, wideReadAdvisor) + const ids = new Set(pageData.municipalityOptions.map((option) => option.id)) + expect(ids.has(administered.id)).toBe(true) + expect(ids.has(outside.id)).toBe(false) + }) +})