feat(campaign): C88 deliberação na atualização — responsável, fio e resolvido (Closes #397)

Migration 20260823_161905_add_municipality_update_deliberation + access por mutationKind + ilha client nas 3 superfícies + specs unit/int/e2e.
This commit is contained in:
Francisco Solla 2026-08-23 17:22:48 -03:00
parent 9467e0f292
commit ad38d5604a
28 changed files with 15986 additions and 54 deletions

View file

@ -6,21 +6,23 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**C140 — Formulários de campanha não apagam mais o digitado em erro (React 19 form reset) (2026-08-23):** fix manual dispatch em 18 formulários de `/campanha` que usavam `<form action={submitAction}>`: campos não controlados voltavam ao defaultValue após qualquer action assentada (sucesso ou erro) do React 19. O fix segue o precedente C139 — `onSubmit` + `preventDefault` + `startTransition` — que mantém `isPending` correto sem reentrar no plumbing de form action. `WizardStepFormChrome` foi alterado uma vez para cobrir 3 wizard steps; os demais 15 forms receberam dispatch manual arquivo a arquivo. `AdvisorPasswordResetButton` (2 micro-forms sem campo digitável) ficou como está. Unit spec novo `campaignWizardStepFormChrome.unit.spec.tsx` cobre o chrome.
**FIX — desbloqueio do deploy (e2e full vermelho, 2026-08-23):** dois bugs do C142 (merge em main no 21-08) que só o e2e **full** do `verify` pegou — o PR do C142 rodou só o blast radius selecionado e passou. **(1) `gate: 'writable'` barrava coordinator/candidate:** `requireCampaignPageActor` checava `advisorEditingAccess(user) === 'none'`, mas esse helper retorna `'none'` para **qualquer** papel não-advisor — inclusive coordinator e candidate, que têm escrita plena. Resultado: `municipios/[slug]/editar` redirecionava o coordinator para a página de detalhe e o e2e `campaignMunicipalities` (coordinator assigns an advisor) estourava timeout. Fix: o gate exige staff primeiro (`isStaffCampaignRole`) e só então aplica a regra `somente_leitura` **para advisors** (`user.role === 'advisor' && advisorEditingAccess(user) === 'none'`). **(2) FAB sem `data-slot`:** o e2e `campaignPermissionProfile` loca `[data-slot="campaign-quick-actions-fab"]`, mas `CampaignQuickActionsFab` nunca renderizou esse atributo — os asserts `toBeVisible()` dos perfis `carteira`/`tudo` falhavam com "element(s) not found" (o `toBeHidden()` do perfil `somente_leitura` passava porque ausência conta como hidden). Fix: `data-slot="campaign-quick-actions-fab"` no div raiz do FAB. Regressões pino: `tests/unit/campaignPageActorGate.unit.spec.ts` (5 casos do gate writable, incluindo coordinator/candidate passando e advisor somente_leitura bloqueado) + assert do data-slot no `campaignQuickActionsDrawer.unit.spec.tsx`. Verificação: tsc/lint/format/cycles verdes (knip: limitação P3 pré-existente), unit 2435 + int 819 verdes, e2e local dos dois specs afetados verdes para os 3 casos que falhavam no deploy (`campaignMunicipalities` coordinator-assigns-advisor e `campaignPermissionProfile` carteira/tudo FAB); os dois remanescentes locais (key-warning dev-only do omnibox no `campaignPermissionProfile:69` e `campaignMunicipalities:638`) passam no build de produção do CI — não são bloqueadores.
**OPS73-FOLLOWUP — re-home do S10 (pixel Meta) no GitHub main, origin do workspace homeserver + race do seedUser no e2e full (2026-08-23):** fechamento dos débitos colhidos no OPS73 (#760). Das três fases planejadas, **duas já estavam resolvidas** por eventos posteriores ao plano de intenção (escrito 2026-08-20): (1) o **S10 não precisou de cherry-pick** — o merge de convergência OPS75 (`c723d6ac`, GitHub como fonte única, 2026-08-21) já trouxe o pixel Meta completo para o GitHub main (os 11 arquivos da feature são byte-idênticos à ponta Forgejo `504f6c37`; migração `20260819_213947_add_site_settings_facebook_pixel_id`, e2e `campaignHomePixel`, unit `campaignNewsletterLead`, changelog/plano todos em main); (2) o **origin do workspace do homeserver** (`~/teqo-deploy`) já era `https://github.com/fsolla/teqo.git` — verificado via SSH, e o `deploy-homeserver.sh` re-aponta idempotentemente a cada deploy. O **código real da entrega** foi o resíduo da F3: a race do usuário compartilhado `dev@payloadcms.com` no e2e full estava **parcialmente fixa** no main (o simplify do S10 trouxe `seedTestUser` create-if-missing + helper `adminHeaders` com `pg_advisory_lock(727001)`), mas restavam 3 pontos de risco: o `afterAll` de `admin.e2e.spec.ts` ainda **deletava** o usuário compartilhado no meio da suíte paralela (`cleanupTestUser`), e dois specs roteavam logins REST diretos **sem o advisory lock** (`admin.e2e.spec.ts` ×2 e a função inline `adminHeaders` sem lock em `frontend.e2e.spec.ts`). A entrega removeu o `cleanupTestUser` (chamada + helper morto, zero call sites) e roteou todos os logins restantes pelo helper com lock — um único ponto de serialização para a suite inteira. Deploy do main (que inclui o S10) via `deploy.yml` disparado manualmente: o `verify` full **flakou** com 3 reds + 5 flaky pré-existentes **não relacionados** ao seedUser (timeouts de `locator.check` em `campaignMunicipalities:115`, `campaignPermissionProfile` C142, etc. — classe consolidada na casa de flakes #553), então o deploy precisa de re-dispatch pós-merge. Dívida capturada: **#805** (`E2E-ADMIN-LOGIN-LOCK`, depends #760) — o `beforeEach` UI login do admin (page-based, form) continua sem o lock (resíduo da própria classe que o #760 ataca); correção inline de docs drift em `AGENTS-infra.md` (tracker já migrado para GitHub no OPS76).
**S12 — âncora `#novidades` quebra no mobile — deep link não leva ao formulário (2026-08-23):** o deep link `jorgesolla1313.com.br/#novidades` (usado para direcionar tráfego orgânico/pago/WhatsApp para a captura) abria a home de campanha no topo no celular: a home é dona do container de scroll interno (`h-dvh overflow-y-auto`, body `overflow-hidden`), então a navegação nativa por fragmento não aterrissa no alvo, e no mobile o reflow do `h-dvh` + imagens eager do hero deslocam o layout após o scroll inicial. Fix contido: novo componente cliente `CampaignHashScroll` (renderiza null, montado no layout da home) que (1) no load direto com hash retenta o scroll até a seção entrar no viewport (rAF + timeouts, parando na primeira interação do usuário — wheel/touch/pointer/teclas de scroll) e (2) ouve `hashchange` para âncoras in-page (CTA do hero, `#bandeiras`, back/forward) sem tocar em `CampaignHero` (continua server component). `el.scrollIntoView()` sem `behavior` herda `scroll-smooth`/`motion-reduce:scroll-auto` do container — `prefers-reduced-motion` respeitado com zero JS. Sem mudança de visual, schema, formulário S9 ou modelo de scroll de outras rotas. E2E novos: describe mobile 390×844 (load direto, CTA, reduced-motion via `emulateMedia`) + load direto desktop (antes sem cobertura), com o teste de load direto desktop em describe próprio para não criar corrida no `beforeAll` de consent do describe de captura (purge+create não seguro sob workers paralelos). Verificação: lint/typecheck/unit + e2e `campaignNewsletter` e `campaignHomePixel` verdes locais; `gate:push` na entrega; PR em main com auto-merge.
**OPS78 — default de launch do worktree opencode → DeepSeek V4 Flash max via Vercel AI Gateway (2026-08-23):** o launch de `pnpm worktree next/plan/new` abria o opencode com o preset `deepseek/deepseek-v4-flash` (rota direta ao provedor). A política mudou: a sessão de launch deve rotear pelo **Vercel AI Gateway** (cache/estabilidade/failover) e expor a variante de raciocínio mais forte (`max`). Mudança config-only (mecanismo OPS26 "trocar modelo = editar a constante"): `OPENCODE_PRESET_MODEL` em `scripts/lib/worktree.mjs` → `vercel/deepseek/deepseek-v4-flash`; o repo `opencode.json` ganhou o override `provider.vercel.models["deepseek/deepseek-v4-flash"].variants` com low/high/max (cada uma com `reasoningEffort`) — o catálogo built-in do opencode não expõe as variantes para esse modelo, e a sobrescreve as adiciona sem quebrar o routing (fix validado ao vivo: `opencode debug config` + `opencode run --model vercel/deepseek/deepseek-v4-flash --variant max`). Fato verificado no source do opencode 1.18: o TUI não tem flag `--variant` (só `opencode run`) e `--model` é estritamente `provider/model`, então a variante é selecionável no TUI e sticky por model (state do opencode) — a verificação do aceite é a linha de launch emitida pelo preset. 8 literais + pin da constante em `tests/unit/worktree.unit.spec.ts` atualizados; textos em `scripts/worktree.mjs` (docblock/help), `.agents/shell/worktree.sh` e `worktree-next-issue/SKILL.md` sincronizados. Anti-goals preservados: cardápio de execução de Issues intocado (`work-issue.md`/`plan-issue.md` + pin `opencodeCommands.unit.spec.ts` seguem com o provider direto), config global do humano intocada, docs históricos congelados. Verificação: `pnpm gate:fast` verde; `gate:push` na entrega; PR em main com auto-merge.
**OPS46-S1 — hook ESM mapeia next/cache incondicionalmente (2026-08-23):** débito da revisão estrutural do OPS46 (issue #789): `tests/helpers/e2eEsmResolve.mjs` remapeava `next/cache` → `next/cache.js` sem tentar a resolução bare — quando o next publicar um `exports` map, o remap podia mascarar erro de resolução real (fail-loud vira fail-silent). Fix no branch `next/cache`: `await nextResolve(specifier, context)` primeiro; só em `ERR_MODULE_NOT_FOUND` remapeia para `next/cache.js`; qualquer outro erro propaga. Branch `react-server` intocado (injeção de conditions não mascara erro). Spec unit novo `tests/unit/e2eEsmResolve.unit.spec.ts` (3 casos com `nextResolve` mockado: bare-ok sem remap, `ERR_MODULE_NOT_FOUND` remapeia com `context` intacto, erro de outro código propaga). Verificação: `gate:fast` verde (unit 2432), sanity OPS72: `playwright test --list` coleta 202 testes com o hook carregado. Gatilho de reavaliação: bump major do next (fallback vira código morto e pode ser removido). Impl plan: `docs/plans/ops46-s1-hook-esm-bare-first-impl.md`.
**OPS73-FOLLOWUP — re-home do S10 (pixel Meta) no GitHub main, origin do workspace homeserver + race do seedUser no e2e full (2026-08-23):** fechamento dos débitos colhidos no OPS73 (#760). Das três fases planejadas, **duas já estavam resolvidas** por eventos posteriores ao plano de intenção (escrito 2026-08-20): (1) o **S10 não precisou de cherry-pick** — o merge de convergência OPS75 (`c723d6ac`, GitHub como fonte única, 2026-08-21) já trouxe o pixel Meta completo para o GitHub main (os 11 arquivos da feature são byte-idênticos à ponta Forgejo `504f6c37`; migração `20260819_213947_add_site_settings_facebook_pixel_id`, e2e `campaignHomePixel`, unit `campaignNewsletterLead`, changelog/plano todos em main); (2) o **origin do workspace do homeserver** (`~/teqo-deploy`) já era `https://github.com/fsolla/teqo.git` — verificado via SSH, e o `deploy-homeserver.sh` re-aponta idempotentemente a cada deploy. O **código real da entrega** foi o resíduo da F3: a race do usuário compartilhado `dev@payloadcms.com` no e2e full estava **parcialmente fixa** no main (o simplify do S10 trouxe `seedTestUser` create-if-missing + helper `adminHeaders` com `pg_advisory_lock(727001)`), mas restavam 3 pontos de risco: o `afterAll` de `admin.e2e.spec.ts` ainda **deletava** o usuário compartilhado no meio da suíte paralela (`cleanupTestUser`), e dois specs roteavam logins REST diretos **sem o advisory lock** (`admin.e2e.spec.ts` ×2 e a função inline `adminHeaders` sem lock em `frontend.e2e.spec.ts`). A entrega removeu o `cleanupTestUser` (chamada + helper morto, zero call sites) e roteou todos os logins restantes pelo helper com lock — um único ponto de serialização para a suite inteira. Deploy do main (que inclui o S10) via `deploy.yml` disparado manualmente: o `verify` full **flakou** com 3 reds + 5 flaky pré-existentes **não relacionados** ao seedUser (timeouts de `locator.check` em `campaignMunicipalities:115`, `campaignPermissionProfile` C142, etc. — classe consolidada na casa de flakes #553), então o deploy precisa de re-dispatch pós-merge. Dívida capturada: **#805** (`E2E-ADMIN-LOGIN-LOCK`, depends #760) — o `beforeEach` UI login do admin (page-based, form) continua sem o lock (resíduo da própria classe que o #760 ataca); correção inline de docs drift em `AGENTS-infra.md` (tracker já migrado para GitHub no OPS76).
**OPS47 — agent-pool/SKILL.md aponta para plano com política pré-main obsoleta (--base stage + auto-merge) (2026-08-23):** docs-only, zero mudança de comportamento. O `.agents/skills/agent-pool/SKILL.md:18` cita `docs/plans/agent-pool-orchestrator.md` como referência viva de arquitetura ("Arquitetura e elegibilidade"), mas o plano (escrito 2026-07-30, era pré-main) ainda descrevia a política obsoleta: PR `--base stage`, `issue-done-on-stage-merge.yml`, promote `stage→main`, `refresh-stage` — realidade removida em 2026-08-01 (promote/refresh) e 2026-08-12 (migration-lock). **Edição linha-a-linha do plano (dono do concern), frame histórico do pool dormente (OPS65) preservado:** overview e resumo executivo passam a "workers PR direto para main" com nota do promote removido; diagrama mermaid `pull_request closed em stage`→`em main` e `merge em main → issue-done-on-main-merge.yml`; contrato de prompt do worker `gh pr create --base stage`→`--base main` (reflete o vivo: `POOL_BASE_REF='main'` em `scripts/agent-pool.mjs:70` e prompt `--base main` em `scripts/lib/agent-pool-prompt.mjs:40-41`); gargalo de migrations reescrito de "job `migration-lock`" para `countOpenSchemaPrs` (`scripts/lib/agent-pool-forgejo.mjs:83`, com nota da remoção do job em 2026-08-12); referências à skill deletada `model-selection` → `scripts/lib/agent-pool-models.mjs`; `scripts/lib/agent-github.mjs` (inexistente) → `agent-forgejo.mjs`; `.github/workflows/agent-pool.yml` e `gh workflow run agent-pool.yml` marcados como removidos no OPS65 (dispatch falha 404); `startingRef: 'stage'`→`POOL_BASE_REF` (=`'main'`); D7/D8/D1 e rabbit holes alinhados (sem `refresh-stage`, sem "slot ocupado até merge em stage"). **SKILL.md:** resíduo L79 "Antes do promote, use dispatch na branch" → nota histórica (promote removido 2026-08-01, workflow removido OPS65). **Touch-ups de comentário/label (sem mudança de lógica):** `tests/unit/agentPoolState.unit.spec.ts:103` "CI flipped on stage merge"→"on main merge"; `scripts/lib/agent-pool-state.mjs:131` "merge in stage"→"merge in main". Fora de escopo (órfão/histórico): `docs/plans/paradigma-agentes-paralelos.md`; código do pool e workflows (dormentes, não tocados). Impl plan: `docs/plans/agent-pool-skill-md-aponta-para-plano-com-politica-pre-impl.md`.
**OPS46-S1 — hook ESM mapeia next/cache incondicionalmente (2026-08-23):** débito da revisão estrutural do OPS46 (issue #789): `tests/helpers/e2eEsmResolve.mjs` remapeava `next/cache` → `next/cache.js` sem tentar a resolução bare — quando o next publicar um `exports` map, o remap podia mascarar erro de resolução real (fail-loud vira fail-silent). Fix no branch `next/cache`: `await nextResolve(specifier, context)` primeiro; só em `ERR_MODULE_NOT_FOUND` remapeia para `next/cache.js`; qualquer outro erro propaga. Branch `react-server` intocado (injeção de conditions não mascara erro). Spec unit novo `tests/unit/e2eEsmResolve.unit.spec.ts` (3 casos com `nextResolve` mockado: bare-ok sem remap, `ERR_MODULE_NOT_FOUND` remapeia com `context` intacto, erro de outro código propaga). Verificação: `gate:fast` verde (unit 2432), sanity OPS72: `playwright test --list` coleta 202 testes com o hook carregado. Gatilho de reavaliação: bump major do next (fallback vira código morto e pode ser removido). Impl plan: `docs/plans/ops46-s1-hook-esm-bare-first-impl.md`.
**OPS46 — infra de teste e2e: coleta local consertada (next/cache) + allocator de município sem colisão no wrap (2026-08-23):** entrega de dois débitos do OPS45 (capture-review-debts). **Fase 1 — e2e local não coleta:** `pnpm exec playwright test --list` falhava em todo spec com `Cannot find module 'node_modules/next/cache'` (e o diagnóstico correto do CI-vs-máquina: não há delta — o CI só roda pelo script `pnpm test:e2e`, que carrega `--conditions=react-server --import=tsx/esm` no `NODE_OPTIONS`; comando bare não tinha nada). Causa: `next@15` não declara `exports` → ESM nativo não resolve `next/cache` (o arquivo é `cache.js`), e `server-only` precisa da condição `react-server`. Fix no runner, zero mudança em produção: hook de resolução ESM vitest-free `tests/helpers/e2eEsmResolve.mjs` (mapeia `next/cache`→`next/cache.js` e injeta `react-server` nas conditions) registrada no processo main via `module.register` e propagada aos workers via `NODE_OPTIONS=--import=e2eEsmLoader.mjs` no `playwright.config.ts` — só quando os flags do script já não estão presentes. Aceite: bare `playwright test --list` coleta 198 testes em 41 arquivos (antes: 0); runs reais bare admin 5/5 e campaign 18/18; `pnpm test:e2e` intocado. **Fase 2 — allocator de município com wrap destrutivo:** a sequência persistente `campaign_fixture_municipality_alloc` nunca é resetada e um ciclo completo de suíte claima ~505 do catálogo de 435 (medido: 432 no int + 73 no e2e) → o wrap acontece em todo ciclo em DB persistente de worktree, e dois runs concorrentes podiam receber o mesmo índice e purgar as linhas vivas um do outro (purge-on-claim). Fix: registry de claims em módulo compartilhado vitest-free `tests/helpers/campaignMunicipalityAllocator.ts` — tabela `campaign_fixture_municipality_claims` (`index` PK, `run_id`, `claimed_at`), claim via `INSERT ON CONFLICT DO NOTHING RETURNING` (conflito = slot vivo → pula; claim stale >2h de run crashado → rouba; tudo ocupado → erro claro de exaustão), release no `cleanup()` dos dois fixtures (int + e2e, só quando claimaram). Guard estático novo no `testMunicipalityAllocatorConventions` (proíbe `nextval`/sequence fora do módulo) + spec int de stress `campaignMunicipalityAllocator.int.spec.ts` (exclusividade viva por 3 wraps, exaustão+recuperação, steal, release). Verificação: tsc/lint/format/cycles verdes (knip: limitação P3 pré-existente de não carregar payload.config, igual no HEAD), unit 2420 + int 819 (92 arquivos) verdes com tabela de claims zerada ao final, e2e local afetado (admin 5/5 + campaign 18/18 bare) verdes; importMap.js restaurado com envs `S3_*` dummy após o `--list` bare regenerá-lo sem elas (classe OPS69 — o guard do #87 cobre no CI). Impl plan: `docs/plans/ops46-infra-teste-e2e-local-allocator-impl.md`.
**FIX — desbloqueio do deploy (e2e full vermelho, 2026-08-23):** dois bugs do C142 (merge em main no 21-08) que só o e2e **full** do `verify` pegou — o PR do C142 rodou só o blast radius selecionado e passou. **(1) `gate: 'writable'` barrava coordinator/candidate:** `requireCampaignPageActor` checava `advisorEditingAccess(user) === 'none'`, mas esse helper retorna `'none'` para **qualquer** papel não-advisor — inclusive coordinator e candidate, que têm escrita plena. Resultado: `municipios/[slug]/editar` redirecionava o coordinator para a página de detalhe e o e2e `campaignMunicipalities` (coordinator assigns an advisor) estourava timeout. Fix: o gate exige staff primeiro (`isStaffCampaignRole`) e só então aplica a regra `somente_leitura` **para advisors** (`user.role === 'advisor' && advisorEditingAccess(user) === 'none'`). **(2) FAB sem `data-slot`:** o e2e `campaignPermissionProfile` loca `[data-slot="campaign-quick-actions-fab"]`, mas `CampaignQuickActionsFab` nunca renderizou esse atributo — os asserts `toBeVisible()` dos perfis `carteira`/`tudo` falhavam com "element(s) not found" (o `toBeHidden()` do perfil `somente_leitura` passava porque ausência conta como hidden). Fix: `data-slot="campaign-quick-actions-fab"` no div raiz do FAB. Regressões pino: `tests/unit/campaignPageActorGate.unit.spec.ts` (5 casos do gate writable, incluindo coordinator/candidate passando e advisor somente_leitura bloqueado) + assert do data-slot no `campaignQuickActionsDrawer.unit.spec.tsx`. Verificação: tsc/lint/format/cycles verdes (knip: limitação P3 pré-existente), unit 2435 + int 819 verdes, e2e local dos dois specs afetados verdes para os 3 casos que falhavam no deploy (`campaignMunicipalities` coordinator-assigns-advisor e `campaignPermissionProfile` carteira/tudo FAB); os dois remanescentes locais (key-warning dev-only do omnibox no `campaignPermissionProfile:69` e `campaignMunicipalities:638`) passam no build de produção do CI — não são bloqueadores.
**E1 — extração do hook compartilhado de busca assíncrona com debounce (2026-08-23):** a 3ª cópia do efeito de busca async (requestId + `setTimeout(250)` + loading/failed) — introduzida pela C143 em `DemandResponsibleMultiSelect` (demand), e presente em `ResponsibleMultiSelect` (C90) e `AsyncSearchCombobox` — vira o hook `useAsyncSearchOptions({ open, query, search, isQueryReady? }) → { options, loading, failed }` (`src/hooks/useAsyncSearchOptions.ts`, novo; constante `ASYNC_SEARCH_DEBOUNCE_MS` exportada). Os 3 componentes consomem o hook e mantêm só o que é seu (grupos por typeLabel, avatares, headings de sugestões do município, `pinnedOptions`, filtro de selecionados, mensagens de vazio); semântica de query vazia preservada por consumidor (Demand busca sempre → sugestões; ResponsibleMultiSelect reseta via `isContactSearchQueryReady`; combobox com gate configurável — o efeito sync `selected←value` fica no componente). Fix pontual: server actions intocadas (continuam props — hook client-safe por injeção), sem schema/migration/access/Consent. Testes: spec unit novo `tests/unit/useAsyncSearchOptions.unit.spec.ts` (renderHook + fake timers, 9 casos: debounce/trim, coalesce, gate reset, closed, failed + retry, in-flight loading, out-of-order, quirk gate→not-ready com resposta stale pinado — comportamento herdado dos 3 originais, registrado na decisão D3 do impl plan). Impl plan `docs/plans/extrair-hook-busca-assincrona-debounce-impl.md`. Verificação: unit 2428 + int 815 verdes, e2e activity/demand (campaignActivity, campaignRegisterDemand, campaignDemandVisibility) verdes em re-run (1 flake de cold-compile pré-existente isolado), tsc/lint/format/cycles/build verdes; knip com o erro de load pré-existente (exit 0); fora de escopo: `WizardMunicipalitySearchStep` (4ª variante, AbortController — defer com gatilho no plano).
**S12 — âncora `#novidades` quebra no mobile — deep link não leva ao formulário (2026-08-23):** o deep link `jorgesolla1313.com.br/#novidades` (usado para direcionar tráfego orgânico/pago/WhatsApp para a captura) abria a home de campanha no topo no celular: a home é dona do container de scroll interno (`h-dvh overflow-y-auto`, body `overflow-hidden`), então a navegação nativa por fragmento não aterrissa no alvo, e no mobile o reflow do `h-dvh` + imagens eager do hero deslocam o layout após o scroll inicial. Fix contido: novo componente cliente `CampaignHashScroll` (renderiza null, montado no layout da home) que (1) no load direto com hash retenta o scroll até a seção entrar no viewport (rAF + timeouts, parando na primeira interação do usuário — wheel/touch/pointer/teclas de scroll) e (2) ouve `hashchange` para âncoras in-page (CTA do hero, `#bandeiras`, back/forward) sem tocar em `CampaignHero` (continua server component). `el.scrollIntoView()` sem `behavior` herda `scroll-smooth`/`motion-reduce:scroll-auto` do container — `prefers-reduced-motion` respeitado com zero JS. Sem mudança de visual, schema, formulário S9 ou modelo de scroll de outras rotas. E2E novos: describe mobile 390×844 (load direto, CTA, reduced-motion via `emulateMedia`) + load direto desktop (antes sem cobertura), com o teste de load direto desktop em describe próprio para não criar corrida no `beforeAll` de consent do describe de captura (purge+create não seguro sob workers paralelos). Verificação: lint/typecheck/unit + e2e `campaignNewsletter` e `campaignHomePixel` verdes locais; `gate:push` na entrega; PR em main com auto-merge.
**C88 — deliberação na atualização (responsável, fio, resolvido) (2026-08-23):** a coordenação agora delibera na própria atualização de município, sem abrir demanda/atividade/Zap paralelo para cada fato (depende do modelo unificado do C87). O card da atualização ganhou três camadas: (1) **responsável** — coordinator/candidate atribuem um `campaignUser` staff do município (assessores da carteira + coordinator/candidate; nunca liderança; reatribuir/limpar livres), salvo server-side com validação de elegibilidade e lock por doc; (2) **fio de comentários** — qualquer staff que pode criar atualização naquele município comenta (array `comments` no doc, precedente `Activity.updates`, hook carimba autor/data), ordem cronológica, composer some quando resolvido; (3) **Resolvido/Reabrir** — coordinator/candidate marcam (audita `resolvedBy`+`resolvedAt`) e reabrem; atualização resolvida permanece legível com badge "Resolvido" + corpo atenuado, e "Em deliberação" aparece quando há fio/responsável. Segurança: canal de escrita novo via `context.mutationKind` (`assignResponsible|appendComment|resolve|reopen`) com split `canUpdateMunicipalityUpdate`/`canDeleteMunicipalityUpdate` (delete segue admin-only), allowlist de campos no `beforeChange` por diff contra o doc original (403 fora dela), zero `overrideAccess: true` novo, leader barrado na raiz (read escopado + ações com `overrideAccess: false`). Superfícies: tab Atualizações do município, feed global `/campanha/atualizacoes` e dossiê compartilham a mesma ilha client com capabilities server-side. Migration `20260823_161905_add_municipality_update_deliberation` (colunas `responsible_id`/`resolved_by_id`/`resolved_at` + tabela `municipality_update_comments`, serializada após `20260820_003426`; o snapshot .json gerado saneia o snapshot stale da cadeia). Corrigido também o alias `canMutateAllocationDecision` que herdava o gate de update do MunicipalityUpdate (agora explícito admin-only — o gate deliberativo jamais alcança `allocationDecision`). Verificação: unit (2423) + int (826, incl. spec novo da deliberação com 11 casos) + build + e2e novos (`campaignMunicipalityDeliberation`, 2 fluxos) e afetados verdes; um e2e pré-existente (B27, gate `writable` do C142) falha também no main limpo — não é regressão desta entrega.
**C142 — interface do /campanha respeita o perfil de permissão do assessor (2026-08-21):** a entrega fechada parte do C141 (que definiu os eixos `visibility`/`editing` no `campaignUser` e os gates server-side). Com o C142, a UI de todos os rascunhos staff do `/campanha` obedece de ponta a ponta o perfil de edição do assessor: com `editing = somente_leitura` não há nenhum controle de escrita em lugar nenhum (listas, detalhes, tabs, wizards); com `editing = tudo` o assessor enxerga/edita tudo o que vê; com `carteira` mantém o comportamento por carteira. Discriminação das entregas: (1) núcleo puro `src/lib/campaignAdvisorProfile.ts` com `advisorEditingScope()`/`rowEditingAllowed()` (retorna `none` para combinação incoerente `editing=tudo` + `visibility≠tudo`) e `gate: 'writable'` em `requireCampaignPageActor` (redireciona para `/campanha`); (2) todas as células compartilhadas (CampaignInlineEditableCell, MunicipalityRelationEditor, PhonesFieldEditor, DemandDescriptionEditor/Card/WorkflowCard, MunicipalityV2\*Cells, MunicipalityGoalAccountCard, ActivityTaskChecklist, SuggestionCard, MunicipalityPledgesPanel, etc.) ganharam o prop `readOnly`; (3) páginas staff — municípios (lista desktop+mobile, detalhe com tabs Overview/Updates/Leaderships/Suggestions, edição), pessoas (invite e criação gated), lideranças, dobradinhas, demandas, atividades, apoiadores, organizações — escondem os botões de criação (`Nova demanda`, `Nova atividade`, `Planejar giro`, `Novo`/`Importar CSV`, `Nova organização`, `Nova liderança`, `Nova dobradinha`) com `editingScope !== 'none'`; (4) home: `homeActionsForRole(role, editingScope)` filtra os 4 wizards para `somente_leitura` (sobra só o atalho de navegação); (5) `shouldMountQuickActionsFab` não monta o FAB para `somente_leitura`; (6) registry de quick actions (`resolveQuickActionsForPath`) recebe `editingScope` e filtra; (7) e2e novo `tests/e2e/campaignPermissionProfile.e2e.spec.ts` cobre os 3 perfis (somente_leitura sem controles, carteira com carteira, tudo com tudo) usando `createCampaignUser` com `visibility`/`editing`. Verificação: tsc/lint/format/knip/cycles/units (2419) + `gate:fast` verdes; PR em main com auto-merge (rebase); nada muda para coordinator/candidate/leader.

View file

@ -0,0 +1 @@
**C88 — deliberação na atualização (responsável, fio, resolvido) (2026-08-23):** a coordenação agora delibera na própria atualização de município, sem abrir demanda/atividade/Zap paralelo para cada fato (depende do modelo unificado do C87). O card da atualização ganhou três camadas: (1) **responsável** — coordinator/candidate atribuem um `campaignUser` staff do município (assessores da carteira + coordinator/candidate; nunca liderança; reatribuir/limpar livres), salvo server-side com validação de elegibilidade e lock por doc; (2) **fio de comentários** — qualquer staff que pode criar atualização naquele município comenta (array `comments` no doc, precedente `Activity.updates`, hook carimba autor/data), ordem cronológica, composer some quando resolvido; (3) **Resolvido/Reabrir** — coordinator/candidate marcam (audita `resolvedBy`+`resolvedAt`) e reabrem; atualização resolvida permanece legível com badge "Resolvido" + corpo atenuado, e "Em deliberação" aparece quando há fio/responsável. Segurança: canal de escrita novo via `context.mutationKind` (`assignResponsible|appendComment|resolve|reopen`) com split `canUpdateMunicipalityUpdate`/`canDeleteMunicipalityUpdate` (delete segue admin-only), allowlist de campos no `beforeChange` por diff contra o doc original (403 fora dela), zero `overrideAccess: true` novo, leader barrado na raiz (read escopado + ações com `overrideAccess: false`). Superfícies: tab Atualizações do município, feed global `/campanha/atualizacoes` e dossiê compartilham a mesma ilha client com capabilities server-side. Migration `20260823_161905_add_municipality_update_deliberation` (colunas `responsible_id`/`resolved_by_id`/`resolved_at` + tabela `municipality_update_comments`, serializada após `20260820_003426`; o snapshot .json gerado saneia o snapshot stale da cadeia). Corrigido também o alias `canMutateAllocationDecision` que herdava o gate de update do MunicipalityUpdate (agora explícito admin-only — o gate deliberativo jamais alcança `allocationDecision`). Verificação: unit (2423) + int (826, incl. spec novo da deliberação com 11 casos) + build + e2e novos (`campaignMunicipalityDeliberation`, 2 fluxos) e afetados verdes; um e2e pré-existente (B27, gate `writable` do C142) falha também no main limpo — não é regressão desta entrega.

View file

@ -0,0 +1,138 @@
# Impl: Deliberação na atualização (responsável, thread, resolvido)
Status: rascunho
Atualizado em: 2026-08-23
Issue: #397 (C88)
Intenção: docs/plans/deliberacao-atualizacao-responsavel-thread.md
Appetite restante: herdado (~1–1,5 dia eng) — um outcome verificável (coordenação atribui, discute e fecha uma atualização)
## Leitura da intenção
- **Outcome:** a coordenação delibera na própria atualização — atribui UM responsável (staff do município), discute num fio visível para quem já lê atualizações daquele município, e marca Resolvido (com reabertura explícita). Atualização resolvida permanece legível no histórico com estado aberto/resolvido óbvio no card; liderança não vê nem participa; modelo unificado de C87 intacto (sem reintroduzir kinds/tipos de sinal).
- **O que NÃO negociar:**
- Leader lockdown na raiz: `resolveProfileScopedRead` já resolve leader → `false` (`src/utilities/access/shared.ts:219`). O fio vive DENTRO do doc e herda o read escopado — nenhuma superfície nova (loader, action, select de staff) pode expor dado deliberativo a leader.
- Sem reintroduzir kinds/tipos de sinal (C87): deliberação é estado e atividade sobre o doc unificado, nunca um novo tipo.
- Imutabilidade do fato registrado: comentário/atribuição/resolvido NÃO editam `body`/`polarity`/`urgent`/`adversarySignal` (correção = nova atualização ou comentário).
- Quem atribui responsável e marca resolvido: só coordinator/candidate (`isCampaignUnrestricted`) + admin. Responsável nunca é liderança.
- Atualização sem responsável pode ter fio e resolvido (campos independentes).
- **O que reavaliar:**
- "Colunas na collection" (hipótese da intenção): confirmada, MAS o caminho de escrita não é um update aberto — `MunicipalityUpdate.update` é admin-only hoje (`canMutateMunicipalityUpdate`, `access/municipalityUpdates.ts:39`). A deliberação exige um canal de update restrito por `context.mutationKind` (precedente Activity `isActivityMutationShortcut`) — decisão D5.
- "Fio = array dentro do doc" (precedente `Activity.updates`): confirmado, mas cria tabela nova (`municipality_update_comments`) + hook derivador que carimba author/createdAt dos itens novos (precedente `deriveActivityFields`, Activity.ts:316-329).
- `author` é FK escalar (`municipality_update.author_id`, não `_rels`): as novas rels single de deliberação (`responsible`, `resolvedBy`) seguem o MESMO formato (coluna `_id` + FK + índice), NÃO o padrão `_rels` da `CampaignDemand` (que é polymorphic/hasMany).
- Notificações: a intenção permite "badge interno no máximo"; existe infra pronta (`notificationEvents`/`createCampaignNotifications`) — cortada nesta fatia (ver Rabbit holes) para caber no appetite, com gatilho de revisitação.
## Abordagem recomendada
```mermaid
flowchart LR
C["Card server-rendered (VM escopada por access)"]
I["Ilha client por card<br/>(select responsável · composer · Resolvido/Reabrir)"]
A["Server actions (zod + runCampaignFormAction)<br/>transaction + advisory lock por doc"]
U["payload.update<br/>context.mutationKind = deliberação"]
G["Collection access: gate por mutationKind<br/>+ FieldAccess por campo"]
H["beforeChange derivador: carimba<br/>comentários novos, barra campos staff"]
VM["loadMunicipalityUpdatesFeed: VM + staff elegível<br/>+ capabilities do ator"]
C --> VM --> I --> A --> U --> G
U --> H
```
**Opções consideradas / Recomendação / Rejeitadas** (formato decision-quality, caro de reverter decidido aqui):
- **D1 — Onde vive o estado deliberativo (responsável/resolvido):** A (rels single `responsible`/`resolvedBy` + coluna `resolvedAt` na própria `MunicipalityUpdate`) | B (collection `MunicipalityUpdateDeliberation` separada) | C (subsumir em `CampaignDemand`).
**Recomendação: A** — um doc = um estado; o fio dentro do doc herda o read escopado (leader lockdown grátis, sem access novo na superfície); zero join/loaders adicionais; o aceite "depende do modelo unificado de C87" é satisfeito sem segundo registro. B cria fronteira cara (access/loaders/consistência/cleanup) para guardar 3 campos; C mistura domínios e é anti-goal explícito da intenção ("sem abrir demanda para cada fato").
- **D2 — Onde vive o fio:** A (array `comments` dentro do doc, precedente `Activity.updates` L646-688) | B (collection `MunicipalityUpdateComment` com relationship parent).
**Recomendação: A** — precedente exato de append atômico (read→append→update com lock), herda read do pai automaticamente (leader bloqueado na raiz), página já carrega o doc com depth. B exige collection nova com access próprio, cleanup de órfãos e N+1 no load do feed. Custo futuro aceito: editar/excluir comentário individual, mentions e reações ficam mais difíceis — barato adiar, gatilho de revisitação anotado.
- **D3 — Forma do autor do comentário:** A (campo `author` rel campaignUser DENTRO do array — Payload materializa `author_id` escalar na tabela do array, precedente `activity_updates.author_id` + índice) | B (campo number puro e resolver nome no VM).
**Recomendação: A** — espelha `Activity.updates` item a item (body/author/createdAt); o hook derivador carimba; `loadCampaignUserNamesByIds` já resolve nomes em lote. B economiza nada (o VM precisa do nome do mesmo jeito) e diverge do precedente.
- **D4 — Representação do estado resolvido:** A (`resolvedAt` date + `resolvedBy` rel; resolvido ⇔ `resolvedAt` não-nulo) | B (boolean `resolved`).
**Recomendação: A** — o timestamp é a auditoria natural (quem/quando), elimina estado booleano flapping, e a reabertura é simplesmente limpar os dois campos. B guarda menos informação e precisaria de coluna extra `resolvedAt` de qualquer forma para o card.
- **D5 — Canal de escrita da deliberação (o caro):** A (estender o dono: `canMutateMunicipalityUpdate` vira gate por `context.mutationKind` deliberativo — `assignResponsible`/`appendComment`/`resolve`/`reopen` — + FieldAccess preciso em cada campo novo; delete continua admin-only) | B (`payload.update` com `overrideAccess: true` dentro das actions) | C (endpoints REST próprios fora da Local API).
**Recomendação: A** — "edit the owner, don't twin": a collection access continua sendo o único portão; `overrideAccess: false` preserva o invariante do repo (nenhum `overrideAccess: true` novo); o precedente exato é `canUpdateActivity` + `isActivityMutationShortcut`. B quebra o invariante de acesso e o fail-closed; C cria superfície paralela à convenção transactions/advisory locks do repo. Defesa em profundidade: o gate por mutationKind é o coarse gate (staff não consegue tocar `body`), a FieldAccess é o precise gate (quem pode tocar cada campo).
- **D6 — Predicados de acesso por perfil:** A (novas funções em `src/utilities/access/municipalityUpdates.ts` reusando primitivos de `shared.ts`: `canAssignUpdateResponsible`/`canResolveMunicipalityUpdate` = `isCampaignUnrestricted`+admin; `canCommentOnMunicipalityUpdate` = mesmo predicado de `canCreateMunicipalityUpdate` — "quem pode criar naquele município comenta"; delegação para evitar duplicação) | B (predicados inline nas actions) | C (helper genérico de mutationKind fora do módulo de acesso).
**Recomendação: A** — o vocabulário RBAC do repo é por módulo de domínio em `access/*` com barrel `campaignAccess`; a regra de comentar é literalmente a regra de criar (gate da intenção "qualquer staff que já pode criar atualização naquele município — B pode comentar"), então `canCommentOnMunicipalityUpdate` DELEGA ao corpo de `canCreateMunicipalityUpdate` (DRY de conhecimento). B espalha regra de acesso em actions; C cria pass-through raso (depth check: não criar).
- **D7 — Lista de staff elegível do select:** A (loader server `loadAssignableUpdateStaff(payload, municipalityID)`: `where: or [ { id: { in: advisors } }, coordinator, candidate ]` sobre `campaignUser` — advisors do município (`Municipality.advisors`) + unrestricted, espelhando `eligibleCampaignStaffWhere`) | B (só `Municipality.advisors`) | C (client busca via endpoint novo).
**Recomendação: A** — o aceite fixa "staff do município (advisors + unrestricted)"; o where é declarativo e reusa o filtro do admin; `filterOptions: eligibleCampaignStaffWhere` no campo `responsible` alinha admin e ferramenta. B quebraria o aceite (coordinator não apareceria como responsável elegível); C cria endpoint novo onde server action já existe.
- **D8 — Feed global multi-município (`/campanha/atualizacoes`):** A (o loader do feed carrega, por página, os staff elegíveis dos municípios presentes — batch: 1 find em `municipality` (id+advisors) + 1 find em `campaignUser` com o where or — e capabilities do ator; o card é a mesma ilha) | B (select carrega sob demanda via endpoint) | C (fio não interativo no feed global, só no detalhe).
**Recomendação: A** — o fluxo da intenção abre a atualização "no feed/detalhe"; o card compartilhado mantém consistência (dossier ganha grátis) e o batch de 2 queries por página cabe no appetite. B viola a convenção (sem endpoints novos); C quebra o fluxo principal do aceite.
- **D9 — Serialização do append/comentário:** A (advisory lock por doc `municipality-update:<id>` na action, ANTES do `payload.update`; o `beforeChange` mantém o lock por município existente `municipality-updates:<municipalityID>` como segunda camada) | B (confiar só no lock por município).
**Recomendação: A** — lock por doc é a granularidade do read-modify-write do array (comentários em atualizações diferentes do mesmo município não se serializam) e é o padrão da casa (`activity:<id>`). Ordem de aquisição consistente em todos os escritores (doc → município) evita deadlock — ver Riscos. B é correto mas coarsa (fila por município para todo append).
**Rejeitadas:** D1-B/C, D2-B, D3-B, D4-B, D5-B/C, D6-B/C, D7-B/C, D8-B/C, D9-B (justificativas acima).
### Componentes / mudanças
- **`src/collections/MunicipalityUpdate.ts`** (estender o dono):
- Novos fields: `responsible` (relationship campaignUser single, `index: true`, `filterOptions: eligibleCampaignStaffWhere`, label "Responsável", access `create/update: canAssignUpdateResponsible`); `resolvedBy` (relationship campaignUser single, `index: true`, label "Resolvido por", `admin.readOnly`, access `create/update: canResolveMunicipalityUpdate`); `resolvedAt` (date, `index: true`, label "Resolvido em", `admin.readOnly`, access `create/update: canResolveMunicipalityUpdate`); `comments` (array, label "Comentários", fields: `body` textarea required maxLength 4000 (espelha `Activity.updates.body`), `author` rel campaignUser `admin.readOnly` + access `canSetMunicipalityUpdateSystemField` (= payloadAdminOnly — o hook deriva, não chega pela entrada), `createdAt` date `admin.readOnly` + mesma access).
- `beforeChange` ganha ramo deliberativo: se `context.mutationKind` ∈ `{assignResponsible, appendComment, resolve, reopen}` → (a) **allowlist** de campos alteráveis `{responsible, resolvedBy, resolvedAt, comments}` — chave fora da allowlist presente em `data` → `APIError 403` (fail-closed: nem admin-texto passa); (b) para `appendComment`, preserva itens antigos e carimba `author`/`createdAt` nos itens novos (precedente `deriveActivityFields`); para `resolve`, carimba `resolvedBy = req.user.id` e `resolvedAt = now`; para `reopen`, limpa ambos. Fora do ramo → comportamento atual intacto (admin).
- `beforeValidate` (admin path) continua validando body/polarity sem mudança; `defaultColumns` ganha `responsible` (opcional).
- **Migration:** `20260823_<HHMM>_add_municipality_update_deliberation` (serializada após `20260820_003426`, via `pnpm migrate:create` + ajuste manual, commitando `.ts` + `.json` + `index.ts`; `pnpm migrate` local):
1. `ALTER TABLE "municipality_update" ADD COLUMN "responsible_id" integer;` + FK `municipality_update_responsible_id_campaign_user_id_fk` (`campaign_user(id)`, `onDelete: set null`, `onUpdate: no action` — espelha `author_id`) + índice `municipality_update_responsible_idx`.
2. Idem para `resolved_by_id` (+ FK `..._resolved_by_id_...` + índice `municipality_update_resolved_by_idx`).
3. `ALTER TABLE "municipality_update" ADD COLUMN "resolved_at" timestamp(3) with time zone;` (nullable — sem backfill; nada existente é deliberado) + índice `municipality_update_resolved_at_idx`.
4. `CREATE TABLE "municipality_update_comments"` espelhando `activity_updates`: `_order` integer, `_parent_id` integer (FK `municipality_update_comments_parent_id_fk` → `municipality_update(id)` `onDelete: cascade`), `id` varchar (PK), `body` varchar, `author_id` integer (índice `municipality_update_comments_author_idx`), `created_at` timestamp(3) with time zone. (Sem `_rels`: author é escalar no array, precedente `activity_updates.author_id`.)
- `down`: drop das 3 colunas/índices/FKs + drop da tabela de comentários.
- **Access — `src/utilities/access/municipalityUpdates.ts`:**
- `MUNICIPALITY_UPDATE_DELIBERATION_MUTATIONS` (const set compartilhada com as actions — exportada do módulo de acesso).
- `canMutateMunicipalityUpdate` → **split**: `canUpdateMunicipalityUpdate` (admin → true; senão gate por mutationKind: `assignResponsible|resolve|reopen` → `isCampaignUnrestricted`; `appendComment` → leader false → unrestricted true → advisor com `advisorEditingAccess !== 'none'` resolve Where de escopo `advisorMunicipalityScopeWhere('municipality', ids)` (espelha `canUpdateActivity`); fora → false) e `canDeleteMunicipalityUpdate` (admin-only, comportamento atual).
- Novos: `canAssignUpdateResponsible: FieldAccess` (admin → true; senão `isCampaignUnrestricted(freshUser)`); `canResolveMunicipalityUpdate: FieldAccess` (idem); `canCommentOnMunicipalityUpdate: Access/FieldAccess` (delega ao corpo de `canCreateMunicipalityUpdate`); `canSetMunicipalityUpdateSystemField: FieldAccess` (`payloadAdminOnly` — para author/createdAt dos comentários).
- Barrel `src/utilities/campaignAccess.ts` reexporta os novos nomes.
- **Server actions — `src/app/(campaign)/campanha/actions/municipalityUpdateDeliberation.ts`** (novo; padrão `createMunicipalityUpdateRecord` + wrappers FormData via `runCampaignFormAction`, retornando `CampaignFormActionState`):
- `assignUpdateResponsible(updateId, responsibleId)`: zod (`lib/schemas/municipalityUpdate.ts` ganha `municipalityUpdateDeliberationSchema`s) → `withPayloadTransaction` → `reloadCampaignActor` → `acquireTextAdvisoryLocks(payload, req, ['municipality-update:<id>'])` → `findByID` (overrideAccess: false — read escopado, fail-closed) → **valida elegibilidade do alvo** (advisors do município + coordinator/candidate; não-liderança) → `update` com `data: { responsible }`, `context: { mutationKind: 'assignResponsible' }`, `overrideAccess: false`.
- `addUpdateComment(updateId, body)`: trim, vazio → erro, >4000 → erro → transaction + lock por doc → `findByID` (select `comments`, `municipality`) → append `{ body }` (hook carimba author/createdAt) → update `context: { mutationKind: 'appendComment' }`.
- `markUpdateResolved(updateId)` / `markUpdateReopened(updateId)`: transaction + lock por doc → update `{ resolvedBy, resolvedAt }` / `{ resolvedBy: null, resolvedAt: null }`, mutationKind `resolve`/`reopen`. Nenhuma das quatro valida estado em nível de action além do access (resolve em já-resolvido e reopen em aberto são idempotentes por construção).
- **UI (Impeccable C — fluxo novo sobre feed existente; shape→craft→critique→polish):**
- `src/utilities/municipality/municipalityUpdatePageData.ts`: `MunicipalityUpdateViewModel` ganha `responsibleId/responsibleName`, `resolvedAt/resolvedByName`, `comments: UpdateCommentViewModel[]` (authorName/createdAt/body) e o feed state ganha `eligibleStaff: {id, name, role}[]` + `capabilities: { canAssign, canComment, canResolve }` (calculadas server-side com os predicados de access — o client não decide permissão; leader nunca chega aqui porque o read já falha na raiz).
- Loader batchado (D8): 1 find `municipality` (id+advisors) por município presente na página + 1 find `campaignUser` com o where or de elegibilidade.
- `MunicipalityUpdateFeed.tsx` → card vira ilha client `MunicipalityUpdateDeliberationCard` (mantém o layout atual: Badge polaridade/urgente/adversário + autor + data + body + voluntários/apoios) + bloco deliberativo: linha de estado ("Aberta" outline / "Resolvida" + "por X em <data>" + card muted), select responsável (NativeSelect do ui kit) quando `canAssign`, fio (comentários em ordem cronológica crescente + composer Textarea + botão "Comentar" quando `canComment`), botões "Marcar como resolvido" / "Reabrir" quando `canResolve`; `useActionState` + `CampaignFormActionMessage` nas três ações; enquanto `pending`, desabilita só o controle disparador (otimismo barato = botão com spinner).
- Superfícies: tab Updates do detalhe do município, feed global `/campanha/atualizacoes` e `MunicipalityDossier` reusam o MESMO card (consistência grátis; dossier herda a ilha). Wrappers `updateFormActions.ts` ganham as três actions FormData (`assignUpdateResponsibleFormAction`, `addUpdateCommentFormAction`, `markUpdateResolvedFormAction`/`markUpdateReopenedFormAction`).
- **`pnpm generate:types`** após schema; **sem `Consent` novo** (deliberação é uso interno staff, não LGPD de cidadão).
### Dados → forma (se aplicável)
- Fio em **ordem cronológica crescente** (leitura de conversa; composer no fim) — o feed de atualizações permanece decrescente (mais recente primeiro); a ordem do fio é o ponto de leitura, não competição com o feed.
- Estado no card: **aberto** = visual ativo atual + badge outline "Aberta" quando houver fio/responsável (sinal de "em deliberação"); **resolvido** = badge "Resolvido" (secondary) + corpo com opacidade reduzida + linha de autoria "Resolvido por X". Polimento fino (variants/opacidade) é barato — decidido na fase UI, semântica fixada aqui.
- "Atualização sem responsável ainda pode ter fio e resolvido": nenhum campo exige o outro; o card renderiza o bloco que existe.
## Fases verificáveis
1. **Schema + migration** (~0,3d): fields na collection → `pnpm migrate:create add-municipality-update-deliberation` → ajuste manual (FKs set null, tabela `comments` espelhando `activity_updates`, down simétrico) → `pnpm migrate` local → `pnpm generate:types` → `tsc --noEmit` + int specs existentes de schema (`campaignMunicipalityUpdate.int.spec.ts`, fixture `campaignFixtures.ts:715`) verdes. **Serializada: próxima migration após `20260820_003426` — nenhuma outra migration entra no PR.**
2. **Access + server actions** (~0,4d): split `canUpdateMunicipalityUpdate`/`canDeleteMunicipalityUpdate`, novos predicados D6, allowlist no `beforeChange` + derivação de comentários; as quatro actions com transaction/lock/elegibilidade; wrappers FormData. Unit specs dos predicados (por perfil: coordinator/candidate/advisor carteira/tudo/somente_leitura/leader/admin) + int novo `campaignMunicipalityUpdateDeliberation.int.spec.ts` (happy paths, leader bloqueado na raiz, advisor fora do escopo negado, elegibilidade do responsável, allowlist 403, append preserva itens).
3. **VM + page data** (~0,2d): campos novos no VM, `loadAssignableUpdateStaff` batchado, capabilities; int do page data (`campaignMunicipalityUpdatePageData.int.spec.ts`, `campaignUpdatesFeedData.int.spec.ts`) atualizados.
4. **UI** (~0,4d): ilha client no card (select/composer/botões/badges) nas três superfícies; verificação visual no browser (shape→craft); estados pending/erro.
5. **Gates + e2e** (~0,3d): e2e afetados (`campaignMunicipalities.e2e.spec.ts`, `campaignUpdatesMobile.e2e.spec.ts`) + novos (coordinator atribui → advisor comenta → resolve → badge; reopen; leader não vê card); `pnpm gate:fast` (inclui `codebaseConventions.unit.spec.ts` — sem re-espelhar where de escopo); push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Notificações (push OU badge interno) — cortado; gatilho de revisitação: infra pronta (`notifyMunicipalityUpdateCommented`/`...Resolved` sobre `createCampaignNotifications` + `resolveMunicipalityStaffRecipientIds`), ~0,1d depois se o produto pedir.
- Editar/apagar comentário; mentions @; anexos; rich text; reações (exigem id estável por comentário fora do padrão array — D2-B seria o pré-requisito).
- Unificar com Demandas/Atividades; SLA/kanban; status intermediários (em andamento etc.) — o aceite tem exatamente um estado binário aberto/resolvido.
- Mudar `body`/`polarity`/`urgent` via deliberação (allowlist bloqueia; correção = nova atualização/comentário).
- Ordenação/remoção de responsável com histórico (reassign sobrescreve; sem trilha de mudanças).
- Paginação do fio (arrays não paginam; limite de comentários: fora de escopo, revisitar se abusar).
- **Adiado com gatilho (pós-simplify):** a projeção deliberativa do feed global (`campaignUpdatesFeedData.ts`) e a do feed do município (`municipalityUpdatePageData.ts`) têm donos distintos — 2 call sites com display divergente (avatar vs names) é intencional; unificar quando surgir o 3º consumidor ou um campo novo que force editar os dois (também absorve a coleta de IDs author+responsible+resolvedBy+comentários).
## Riscos e mitigação
- **Leader lockdown vazando:** fio dentro do doc herda `resolveProfileScopedRead` (leader → false na raiz) e as actions usam `findByID` com `overrideAccess: false` (fail-closed: quem não lê não comenta/atribui). Int spec pina leader negado nas quatro actions.
- **Deadlock entre locks:** a action adquire `municipality-update:<id>` (doc) ANTES do update; o `beforeChange` adquire `municipality-updates:<municipalityID>` (município) DENTRO do update. Ordem consistente doc→município em todos os escritores (create/delete só tocam o lock de município) — sem inversão possível; documentar no hook.
- **Perda de append (read-modify-write):** o lock por doc serializa o par read→update dentro da mesma transaction; `overrideAccess: false` mantém os filtros de escopo no read.
- **Testes que asserem fields exatos:** `campaignMunicipalityUpdate.int.spec.ts:195` usa `arrayContaining` (novos fields não quebram); fixture `campaignFixtures.ts:715` cria com defaults — campos novos nullable/array vazio; verificar `codebaseConventions.unit.spec.ts:684` (export paths do barrel).
- **Reintroduzir kind/tipo de sinal:** nenhum enum novo; estado = `resolvedAt` + rels; UI usa apenas badges derivados.
- **Select de responsável com portfólio:** o where de elegibilidade é server-side (advisors do município + unrestricted) — assessor nunca vê staff fora do município; int spec pina o conteúdo do select.
- **Update admin-only preservado:** admin path do `beforeChange`/`beforeValidate` intacto fora do ramo `mutationKind`; delete segue admin-only (split explícito).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: UM responsável atribuível (staff do município, nunca liderança); fio visível para quem lê atualizações do município; Resolvido + reabertura explícita; resolvida permanece legível com estado óbvio no card; leader não vê nem participa; modelo C87 intacto (sem kinds)
- [ ] Invariantes AGENTS/engineering-standards: sem `overrideAccess: true` novo; migration commitada serializada (topo da cadeia, próxima após `20260820_003426`); `push: false`; access por perfil em `src/utilities/access/*` reexportado no barrel; identificadores em inglês, labels/copy pt-BR; `admin.group: 'Campanha'`; "edit the owner" (collection existente + access module existente, sem twin)
- [ ] Testes de domínio previstos: unit (predicados de acesso, allowlist, derivação de comentários) + int novo de deliberação + ints de page data atualizados + e2e afetados/novos; gates completos (`tsc`, lint, format, knip, cycles, unit+int, build)
## Self-score decision-quality
1. **Decisões caras têm rejeitadas?** Sim — D1–D9 com A/B/C e justificativas; as duas fronteiras caras (onde vive o estado, como se escreve num doc admin-only) são as mais deliberadas (D1, D5). (1/1)
2. **Abordagem cabe no appetite?** Sim — reuso integral de precedentes (`Activity.updates`, `canUpdateActivity`, `deriveActivityFields`, `acquireTextAdvisoryLocks`, `runCampaignFormAction`); ~1,6d estimado em fases com cortes nomeados (notificações fora). (1/1)
3. **Rabbit holes nomeados?** Sim — 7 itens com gatilhos de revisitação (notificações, edição de comentário). (1/1)
4. **Depth check: reusa shells/helpers existentes?** Sim — collection/access/VM/card são os donos atuais (edit-the-owner); novos símbolos mínimos (1 módulo de actions, 1 card client); sem provider/contexto novo; sem endpoint novo. (1/1)
5. **Intenção permanece satisfeita?** Sim — outcome do aceite mapeado 1:1 em fases; nenhuma decisão de engenharia reescreve regra de produto (atribuição/resolvido só unrestricted; comentar = criar; leader fora). (1/1)
**Self-score: 5/5** (gate ≥4).

View file

@ -54,7 +54,7 @@ export default async function CampaignUpdatesPage({ searchParams }: CampaignUpda
<CampaignListPendingBoundary>
<CampaignUpdatesFilters state={canonicalState} facets={facets} isStaff={isStaff} />
<CampaignListResults>
<CampaignUpdatesFeed cards={feed.cards} />
<CampaignUpdatesFeed cards={feed.cards} deliberation={feed.deliberation} />
<CampaignListFooter
totalDocs={feed.totalDocs}
singular="atualização encontrada"

View file

@ -381,7 +381,7 @@ export const UpdatesTab = async ({
isStaff={isStaff}
/>
)}
<MunicipalityUpdateFeed updates={feed.updates} />
<MunicipalityUpdateFeed updates={feed.updates} deliberation={feed.deliberation} />
<CampaignListPagination
page={feed.page}
totalPages={feed.totalPages}

View file

@ -0,0 +1,272 @@
'use server'
import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import {
MUNICIPALITY_UPDATE_NO_MUNICIPALITY_MESSAGE,
MUNICIPALITY_UPDATE_RESPONSIBLE_NOT_ELIGIBLE_MESSAGE,
municipalityUpdateCommentSchema,
municipalityUpdateResolveSchema,
municipalityUpdateResponsibleSchema,
type MunicipalityUpdateCommentInput,
type MunicipalityUpdateResponsibleInput,
} from '@/lib/schemas/municipalityUpdate'
import type { CampaignUser } from '@/payload-types'
import { assignableUpdateStaffWhere } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import {
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
const deliberationLock = (updateId: number) => [`municipality-update:${updateId}`]
/**
* C88 — the assignee must be staff who already sees the territory: an advisor
* of the municipality or coordinator/candidate (unrestricted). The check reuses
* the exact `campaignUser` where the feed loader offers in the select
* (`assignableUpdateStaffWhere`), so the UI options and the save validation
* can never drift. Leaders are never assignable.
*/
const assertAssignableResponsible = async (
payload: Payload,
actor: CampaignUser,
req: { transactionID: number | string },
municipalityID: number,
responsibleId: number,
): Promise<void> => {
const municipality = await payload.findByID({
collection: 'municipality',
id: municipalityID,
depth: 0,
select: { advisors: true },
user: actor,
overrideAccess: false,
req,
})
const advisorIDs = uniqueRelationshipIds(municipality.advisors)
const found = await payload.find({
collection: 'campaignUser',
where: {
and: [{ id: { equals: responsibleId } }, assignableUpdateStaffWhere(advisorIDs)],
},
depth: 0,
limit: 1,
pagination: false,
user: actor,
overrideAccess: false,
req,
})
if (found.docs.length === 0) throw new Error(MUNICIPALITY_UPDATE_RESPONSIBLE_NOT_ELIGIBLE_MESSAGE)
}
export const assignMunicipalityUpdateResponsibleRecord = async (
payload: Payload,
actor: CampaignUser,
input: MunicipalityUpdateResponsibleInput,
) => {
const { updateId, responsibleId } = municipalityUpdateResponsibleSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
await acquireTextAdvisoryLocks(payload, req, deliberationLock(updateId))
const update = await payload.findByID({
collection: 'municipalityUpdate',
id: updateId,
depth: 0,
select: { municipality: true },
user: currentActor,
overrideAccess: false,
req,
})
const municipalityID = relationshipId(update.municipality)
if (municipalityID === null) {
throw new Error(MUNICIPALITY_UPDATE_NO_MUNICIPALITY_MESSAGE)
}
if (responsibleId !== null) {
await assertAssignableResponsible(payload, currentActor, req, municipalityID, responsibleId)
}
return payload.update({
collection: 'municipalityUpdate',
id: updateId,
data: { responsible: responsibleId },
depth: 0,
user: currentActor,
overrideAccess: false,
context: { mutationKind: 'assignResponsible' },
req,
})
},
{ beginFailureMessage: 'Não foi possível definir o responsável da atualização.' },
)
}
export const addMunicipalityUpdateCommentRecord = async (
payload: Payload,
actor: CampaignUser,
input: MunicipalityUpdateCommentInput,
) => {
const { updateId, body } = municipalityUpdateCommentSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
await acquireTextAdvisoryLocks(payload, req, deliberationLock(updateId))
const update = await payload.findByID({
collection: 'municipalityUpdate',
id: updateId,
depth: 0,
select: { comments: true },
user: currentActor,
overrideAccess: false,
req,
})
const comments = update.comments ?? []
return payload.update({
collection: 'municipalityUpdate',
id: updateId,
data: { comments: [...comments, { body }] },
depth: 0,
user: currentActor,
overrideAccess: false,
context: { mutationKind: 'appendComment' },
req,
})
},
{ beginFailureMessage: 'Não foi possível registrar o comentário.' },
)
}
const setMunicipalityUpdateResolvedRecord = async (
payload: Payload,
actor: CampaignUser,
input: { updateId: number },
resolved: boolean,
) => {
const { updateId } = municipalityUpdateResolveSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
await acquireTextAdvisoryLocks(payload, req, deliberationLock(updateId))
// The hook owns the audit trail: it stamps `resolvedBy` from the acting
// user and `resolvedAt` from the server clock — the action only signals
// the transition.
return payload.update({
collection: 'municipalityUpdate',
id: updateId,
data: resolved ? { resolvedAt: new Date().toISOString() } : { resolvedAt: null },
depth: 0,
user: currentActor,
overrideAccess: false,
context: { mutationKind: resolved ? 'resolve' : 'reopen' },
req,
})
},
{
beginFailureMessage: resolved
? 'Não foi possível marcar a atualização como resolvida.'
: 'Não foi possível reabrir a atualização.',
},
)
}
export const markMunicipalityUpdateResolvedRecord = async (
payload: Payload,
actor: CampaignUser,
input: { updateId: number },
) => setMunicipalityUpdateResolvedRecord(payload, actor, input, true)
export const markMunicipalityUpdateReopenedRecord = async (
payload: Payload,
actor: CampaignUser,
input: { updateId: number },
) => setMunicipalityUpdateResolvedRecord(payload, actor, input, false)
const revalidateDeliberationSurfaces = (): void => {
revalidatePath('/campanha/municipios/[slug]', 'page')
revalidatePath('/campanha/atualizacoes')
}
export const assignUpdateResponsibleFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const updateId = Number(formData.get('updateId'))
const rawResponsible = formData.get('responsibleId')
const { payload, actor } = await getCampaignActionContext()
await assignMunicipalityUpdateResponsibleRecord(payload, actor, {
updateId,
responsibleId: rawResponsible ? Number(rawResponsible) : null,
})
revalidateDeliberationSurfaces()
return { message: 'Responsável definido.' }
},
genericMessage: 'Não foi possível definir o responsável. Verifique o usuário e seu acesso.',
safeMessages: [MUNICIPALITY_UPDATE_RESPONSIBLE_NOT_ELIGIBLE_MESSAGE],
})
export const addUpdateCommentFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const { payload, actor } = await getCampaignActionContext()
await addMunicipalityUpdateCommentRecord(payload, actor, {
updateId: Number(formData.get('updateId')),
body: String(formData.get('body') ?? ''),
})
revalidateDeliberationSurfaces()
return { message: 'Comentário registrado.' }
},
genericMessage: 'Não foi possível registrar o comentário. Verifique seu acesso.',
})
export const markUpdateResolvedFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const { payload, actor } = await getCampaignActionContext()
await markMunicipalityUpdateResolvedRecord(payload, actor, {
updateId: Number(formData.get('updateId')),
})
revalidateDeliberationSurfaces()
return { message: 'Atualização marcada como resolvida.' }
},
genericMessage: 'Não foi possível marcar a atualização como resolvida.',
})
export const markUpdateReopenedFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const { payload, actor } = await getCampaignActionContext()
await markMunicipalityUpdateReopenedRecord(payload, actor, {
updateId: Number(formData.get('updateId')),
})
revalidateDeliberationSurfaces()
return { message: 'Atualização reaberta.' }
},
genericMessage: 'Não foi possível reabrir a atualização.',
})

View file

@ -15,15 +15,31 @@ import {
municipalityUpdatePolarities,
municipalityUpdatePolarityLabels,
} from '@/lib/schemas/municipalityUpdate'
import { trimmedText } from '@/lib/text'
import {
canAssignUpdateResponsible,
canCommentOnMunicipalityUpdate,
canCreateMunicipalityUpdate,
canMutateMunicipalityUpdate,
canDeleteMunicipalityUpdate,
canReadMunicipalityUpdate,
canResolveMunicipalityUpdate,
canSetMunicipalityUpdateAuthor,
canSetMunicipalityUpdateSystemField,
canUpdateMunicipalityUpdate,
eligibleCampaignStaffWhere,
MUNICIPALITY_UPDATE_DELIBERATION_MUTATIONS,
} from '@/utilities/campaignAccess'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
const DERIVED_MUNICIPALITY_UPDATE_CONTEXT = 'municipalityUpdateDerivedField'
/** C88 — fields the deliberative writes may touch; everything else is a 403. */
const MUNICIPALITY_UPDATE_DELIBERATION_ALLOWLIST = [
'responsible',
'resolvedBy',
'resolvedAt',
'comments',
]
const MUNICIPALITY_UPDATE_POLARITY_OPTIONS = municipalityUpdatePolarities.map((value) => ({
label: municipalityUpdatePolarityLabels[value],
value,
@ -96,6 +112,74 @@ const recomputeMunicipalityLastUpdateAt = async (
}
}
/**
* C88 — the deliberative write path (`assignResponsible`/`appendComment`/
* `resolve`/`reopen`). Fail-closed on three fronts: only the allowlisted
* fields may CHANGE (Payload's beforeChange `data` is the merged doc, so the
* gate is the diff against `originalDoc`), comment items are stamped
* (author/createdAt) exactly like `Activity.updates`, and the resolve/reopen
* transitions stamp or clear the audit fields from the acting user — never
* from the request body. The raw update path (admin) is untouched:
* non-deliberative updates return here without any change.
*/
const deriveMunicipalityUpdateDeliberation: CollectionBeforeChangeHook = ({
context,
data,
operation,
originalDoc,
req,
}) => {
if (operation !== 'update' || !data) return data
const mutationKind = context?.mutationKind
if (
typeof mutationKind !== 'string' ||
!MUNICIPALITY_UPDATE_DELIBERATION_MUTATIONS.has(mutationKind)
) {
return data
}
const previous = (originalDoc ?? {}) as Record<string, unknown>
const changedKeys = Object.keys(data).filter((key) => {
const next = data[key]
const before = previous[key]
if (
(typeof next === 'object' && next !== null) ||
(typeof before === 'object' && before !== null)
) {
return JSON.stringify(next) !== JSON.stringify(before)
}
return next !== before
})
const forbiddenKeys = changedKeys.filter(
(key) => !MUNICIPALITY_UPDATE_DELIBERATION_ALLOWLIST.includes(key),
)
if (forbiddenKeys.length > 0) {
throw new APIError('Esta atualização não pode ser alterada por deliberação.', 403)
}
if (mutationKind === 'appendComment') {
const previousComments = Array.isArray(originalDoc?.comments) ? originalDoc.comments : []
const nextComments = Array.isArray(data.comments) ? data.comments : []
data.comments = nextComments.map((comment: Record<string, unknown>, index: number) => {
if (index < previousComments.length) return previousComments[index]
return {
body: trimmedText(comment.body),
author: req.user?.collection === 'campaignUser' ? req.user.id : null,
createdAt: new Date().toISOString(),
}
})
} else if (mutationKind === 'resolve') {
data.resolvedAt = new Date().toISOString()
if (req.user?.collection === 'campaignUser') data.resolvedBy = req.user.id
} else if (mutationKind === 'reopen') {
data.resolvedBy = null
data.resolvedAt = null
}
return data
}
const lockMunicipalitiesBeforeChange: CollectionBeforeChangeHook = async ({
context,
data,
@ -182,17 +266,17 @@ export const MunicipalityUpdate: CollectionConfig = {
admin: {
group: 'Campanha',
useAsTitle: 'body',
defaultColumns: ['municipality', 'author', 'polarity', 'urgent', 'createdAt'],
defaultColumns: ['municipality', 'author', 'responsible', 'polarity', 'urgent', 'createdAt'],
},
access: {
create: canCreateMunicipalityUpdate,
read: canReadMunicipalityUpdate,
update: canMutateMunicipalityUpdate,
delete: canMutateMunicipalityUpdate,
update: canUpdateMunicipalityUpdate,
delete: canDeleteMunicipalityUpdate,
},
hooks: {
beforeValidate: [validateMunicipalityUpdatePolarity],
beforeChange: [lockMunicipalitiesBeforeChange],
beforeChange: [deriveMunicipalityUpdateDeliberation, lockMunicipalitiesBeforeChange],
beforeDelete: [lockMunicipalityBeforeDelete],
afterChange: [recomputeChangedMunicipalities],
afterDelete: [recomputeDeletedMunicipality],
@ -266,5 +350,97 @@ export const MunicipalityUpdate: CollectionConfig = {
label: 'Novos apoios',
min: 0,
},
{
name: 'responsible',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Responsável',
index: true,
filterOptions: eligibleCampaignStaffWhere,
admin: {
description: 'Staff do município ou coordenação que acompanha este fato.',
},
access: {
create: canAssignUpdateResponsible,
update: canAssignUpdateResponsible,
},
},
{
name: 'resolvedBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Resolvido por',
index: true,
admin: {
readOnly: true,
},
access: {
create: canResolveMunicipalityUpdate,
update: canResolveMunicipalityUpdate,
},
},
{
name: 'resolvedAt',
type: 'date',
label: 'Resolvido em',
index: true,
admin: {
readOnly: true,
},
access: {
create: canResolveMunicipalityUpdate,
update: canResolveMunicipalityUpdate,
},
},
{
name: 'comments',
type: 'array',
label: 'Comentários',
admin: {
description: 'Fio de deliberação sobre esta atualização.',
},
// C88 — write gated by the comment rule; READ follows the collection
// gate (`canReadMunicipalityUpdate`): anyone who can read the update
// reads its thread, even a somente_leitura advisor.
access: {
create: canCommentOnMunicipalityUpdate,
update: canCommentOnMunicipalityUpdate,
},
fields: [
{
name: 'body',
type: 'textarea',
label: 'Comentário',
maxLength: 4000,
required: true,
},
{
name: 'author',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Autor',
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetMunicipalityUpdateSystemField,
update: canSetMunicipalityUpdateSystemField,
},
},
{
name: 'createdAt',
type: 'date',
label: 'Criado em',
admin: {
readOnly: true,
},
access: {
create: canSetMunicipalityUpdateSystemField,
update: canSetMunicipalityUpdateSystemField,
},
},
],
},
],
}

View file

@ -1,5 +1,15 @@
import { Inbox } from 'lucide-react'
import {
addUpdateCommentFormAction,
assignUpdateResponsibleFormAction,
markUpdateReopenedFormAction,
markUpdateResolvedFormAction,
} from '@/app/(campaign)/campanha/actions/municipalityUpdateDeliberation'
import {
MunicipalityUpdateDeliberation,
type MunicipalityUpdateDeliberationFormActions,
} from '@/components/campaign/municipality/MunicipalityUpdateDeliberation'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignUserAvatar } from '@/components/campaign/shared/CampaignUserAvatar'
import { Badge } from '@/components/ui/Badge'
@ -8,13 +18,27 @@ import {
municipalityUpdatePolarityLabels,
} from '@/lib/schemas/municipalityUpdate'
import type { CampaignUpdatesFeedCard } from '@/utilities/municipality/campaignUpdatesFeedData'
import type { MunicipalityUpdateDeliberationContext } from '@/utilities/municipality/municipalityUpdatePageData'
const deliberationFormActions: MunicipalityUpdateDeliberationFormActions = {
assign: assignUpdateResponsibleFormAction,
comment: addUpdateCommentFormAction,
resolve: markUpdateResolvedFormAction,
reopen: markUpdateReopenedFormAction,
}
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'short',
timeStyle: 'short',
})
const CampaignUpdatesFeedItem = ({ card }: { card: CampaignUpdatesFeedCard }) => (
const CampaignUpdatesFeedItem = ({
card,
deliberation,
}: {
card: CampaignUpdatesFeedCard
deliberation: MunicipalityUpdateDeliberationContext
}) => (
<li className="flex gap-3 rounded-none border-b border-border p-4 last:border-b-0 md:rounded-xl md:border md:last:border-b">
<CampaignUserAvatar
name={card.author.name}
@ -23,7 +47,7 @@ const CampaignUpdatesFeedItem = ({ card }: { card: CampaignUpdatesFeedCard }) =>
className="mt-0.5 size-10 shrink-0"
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<p className="whitespace-pre-wrap text-sm">{card.body ?? 'Sem texto.'}</p>
<p className="text-sm whitespace-pre-wrap">{card.body ?? 'Sem texto.'}</p>
<div className="flex flex-wrap gap-1.5">
<Badge variant={municipalityUpdatePolarityBadgeVariant[card.polarity]}>
{municipalityUpdatePolarityLabels[card.polarity]}
@ -38,11 +62,28 @@ const CampaignUpdatesFeedItem = ({ card }: { card: CampaignUpdatesFeedCard }) =>
<span aria-hidden="true"> · </span>
{dateTimeFormatter.format(new Date(card.createdAt))}
</p>
<MunicipalityUpdateDeliberation
updateId={card.id}
responsibleId={card.deliberation.responsibleId}
responsibleName={card.deliberation.responsibleName}
resolvedAt={card.deliberation.resolvedAt}
resolvedByName={card.deliberation.resolvedByName}
comments={card.deliberation.comments}
eligibleStaff={deliberation.eligibleStaff}
capabilities={deliberation.capabilities}
formActions={deliberationFormActions}
/>
</div>
</li>
)
export const CampaignUpdatesFeed = ({ cards }: { cards: CampaignUpdatesFeedCard[] }) => {
export const CampaignUpdatesFeed = ({
cards,
deliberation,
}: {
cards: CampaignUpdatesFeedCard[]
deliberation: MunicipalityUpdateDeliberationContext
}) => {
if (cards.length === 0) {
return (
<CampaignListEmptyState
@ -56,7 +97,7 @@ export const CampaignUpdatesFeed = ({ cards }: { cards: CampaignUpdatesFeedCard[
return (
<ul className="-mx-4 my-0 flex list-none flex-col [&>li]:mt-0 md:mx-0 md:gap-4">
{cards.map((card) => (
<CampaignUpdatesFeedItem key={card.id} card={card} />
<CampaignUpdatesFeedItem key={card.id} card={card} deliberation={deliberation} />
))}
</ul>
)

View file

@ -336,7 +336,10 @@ export const MunicipalityDossier = ({
title={`Sinais e atualizações recentes (${formatElectionNumber(data.signals.totalCount)})`}
seeAllHref={`/campanha/municipios/${view.slug}?tab=updates`}
/>
<MunicipalityUpdateFeed updates={data.signals.rows} />
<MunicipalityUpdateFeed
updates={data.signals.rows}
deliberation={data.signals.deliberation}
/>
</section>
{/* Perfil do eleitorado — IBGE Censo 2022 (A8). */}

View file

@ -0,0 +1,277 @@
'use client'
import { useActionState, useEffect, useRef } from 'react'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { MUNICIPALITY_UPDATE_COMMENT_MAX_LENGTH } from '@/lib/schemas/municipalityUpdate'
import type { MunicipalityUpdateDeliberationCapabilities } from '@/utilities/campaignAccess'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import type {
EligibleUpdateStaffMember,
MunicipalityUpdateCommentViewModel,
} from '@/utilities/municipality/municipalityUpdatePageData'
/** C88 — the four deliberation server actions, injected by the server feeds. */
export type MunicipalityUpdateDeliberationFormActions = {
assign: (state: CampaignFormActionState, formData: FormData) => Promise<CampaignFormActionState>
comment: (state: CampaignFormActionState, formData: FormData) => Promise<CampaignFormActionState>
resolve: (state: CampaignFormActionState, formData: FormData) => Promise<CampaignFormActionState>
reopen: (state: CampaignFormActionState, formData: FormData) => Promise<CampaignFormActionState>
}
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'short',
timeStyle: 'short',
})
const staffRoleLabels: Record<'coordinator' | 'advisor' | 'candidate', string> = {
coordinator: 'Coordenador Geral',
advisor: 'Assessor',
candidate: 'Candidato',
}
type MunicipalityUpdateDeliberationProps = {
updateId: number
responsibleId: number | null
responsibleName: string | null
resolvedAt: string | null
resolvedByName: string | null
comments: MunicipalityUpdateCommentViewModel[]
eligibleStaff: EligibleUpdateStaffMember[]
capabilities: MunicipalityUpdateDeliberationCapabilities
formActions: MunicipalityUpdateDeliberationFormActions
}
/**
* C88 — the deliberation island on an update card: assignee select, comment
* thread and the Resolvido/Reabrir transition. The server passes the actor's
* capabilities; the client only renders what the actor may do. Leader lockdown
* is upstream — this card never renders for a leader.
*/
export const MunicipalityUpdateDeliberation = ({
updateId,
responsibleId,
responsibleName,
resolvedAt,
resolvedByName,
comments,
eligibleStaff,
capabilities,
formActions,
}: MunicipalityUpdateDeliberationProps) => {
const isResolved = resolvedAt !== null
const inDeliberation = !isResolved && (responsibleId !== null || comments.length > 0)
return (
<div className="flex flex-col gap-3">
{isResolved || inDeliberation ? (
<div className="flex flex-wrap items-center gap-2">
{isResolved ? (
<Badge variant="secondary">Resolvido</Badge>
) : (
<Badge variant="outline">Em deliberação</Badge>
)}
</div>
) : null}
{isResolved ? (
<p className="text-xs text-muted-foreground">
Resolvido por <span className="font-medium text-foreground">{resolvedByName}</span>
{resolvedAt ? ` em ${dateTimeFormatter.format(new Date(resolvedAt))}` : ''}
</p>
) : null}
{capabilities.canAssign || responsibleId !== null ? (
<ResponsibleBlock
updateId={updateId}
responsibleId={responsibleId}
responsibleName={responsibleName}
eligibleStaff={eligibleStaff}
canAssign={capabilities.canAssign}
formAction={formActions.assign}
/>
) : null}
{comments.length > 0 || capabilities.canComment ? (
<CommentThread
updateId={updateId}
comments={comments}
canComment={capabilities.canComment && !isResolved}
formAction={formActions.comment}
/>
) : null}
{capabilities.canResolve ? (
<div className="flex justify-end">
<ResolveAction
updateId={updateId}
isResolved={isResolved}
formAction={isResolved ? formActions.reopen : formActions.resolve}
/>
</div>
) : null}
</div>
)
}
const ResponsibleBlock = ({
updateId,
responsibleId,
responsibleName,
eligibleStaff,
canAssign,
formAction,
}: {
updateId: number
responsibleId: number | null
responsibleName: string | null
eligibleStaff: EligibleUpdateStaffMember[]
canAssign: boolean
formAction: MunicipalityUpdateDeliberationFormActions['assign']
}) => {
const [state, submitAction, isPending] = useActionState(formAction, {})
if (!canAssign) {
return (
<p className="text-sm">
<span className="font-medium">Responsável:</span>{' '}
<span className="text-muted-foreground">{responsibleName}</span>
</p>
)
}
return (
<form action={submitAction} className="flex flex-wrap items-center gap-2">
<input type="hidden" name="updateId" value={updateId} />
<label htmlFor={`update-responsible-${updateId}`} className="text-sm font-medium">
Responsável
</label>
<NativeSelect
id={`update-responsible-${updateId}`}
name="responsibleId"
size="sm"
defaultValue={responsibleId === null ? 'none' : String(responsibleId)}
disabled={isPending}
>
<NativeSelectOption value="none">Sem responsável</NativeSelectOption>
{eligibleStaff.map((staff) => (
<NativeSelectOption key={staff.id} value={String(staff.id)}>
{staff.name} ({staffRoleLabels[staff.role]})
</NativeSelectOption>
))}
</NativeSelect>
<Button type="submit" size="sm" variant="outline" disabled={isPending}>
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Definir
</Button>
{state.message ? (
<CampaignFormActionMessage
state={state}
errorTitle="Não foi possível definir o responsável"
/>
) : null}
</form>
)
}
const CommentThread = ({
updateId,
comments,
canComment,
formAction,
}: {
updateId: number
comments: MunicipalityUpdateCommentViewModel[]
canComment: boolean
formAction: MunicipalityUpdateDeliberationFormActions['comment']
}) => {
const [state, submitAction, isPending] = useActionState(formAction, {})
const formRef = useRef<HTMLFormElement>(null)
useEffect(() => {
if (state.status === 'success' && formRef.current) formRef.current.reset()
}, [state])
return (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Discussão</p>
{comments.length > 0 ? (
<ul className="flex flex-col gap-2">
{comments.map((comment) => (
<li
key={comment.id ?? `${comment.authorName}-${comment.createdAt}-${comment.body}`}
className="flex flex-col gap-0.5 rounded-lg border px-3 py-2"
>
<p className="text-xs">
<span className="font-medium text-foreground">{comment.authorName}</span>
{comment.createdAt ? (
<>
<span aria-hidden="true"> · </span>
<span className="text-muted-foreground">
{dateTimeFormatter.format(new Date(comment.createdAt))}
</span>
</>
) : null}
</p>
<p className="text-sm whitespace-pre-wrap text-muted-foreground">{comment.body}</p>
</li>
))}
</ul>
) : null}
{canComment ? (
<form ref={formRef} action={submitAction} className="flex flex-col gap-2">
<input type="hidden" name="updateId" value={updateId} />
<Textarea
name="body"
rows={2}
maxLength={MUNICIPALITY_UPDATE_COMMENT_MAX_LENGTH}
placeholder="Escrever comentário…"
disabled={isPending}
/>
{state.message ? (
<CampaignFormActionMessage state={state} errorTitle="Não foi possível comentar" />
) : null}
<div className="flex justify-end">
<Button type="submit" size="sm" variant="outline" disabled={isPending}>
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Comentar
</Button>
</div>
</form>
) : null}
</div>
)
}
const ResolveAction = ({
updateId,
isResolved,
formAction,
}: {
updateId: number
isResolved: boolean
formAction: MunicipalityUpdateDeliberationFormActions['resolve']
}) => {
const [state, submitAction, isPending] = useActionState(formAction, {})
return (
<form action={submitAction}>
<input type="hidden" name="updateId" value={updateId} />
{state.message ? (
<CampaignFormActionMessage
state={state}
errorTitle={isResolved ? 'Não foi possível reabrir' : 'Não foi possível marcar'}
/>
) : null}
<Button type="submit" variant={isResolved ? 'outline' : 'default'} disabled={isPending}>
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
{isResolved ? 'Reabrir' : 'Marcar como resolvido'}
</Button>
</form>
)
}

View file

@ -1,9 +1,29 @@
import {
addUpdateCommentFormAction,
assignUpdateResponsibleFormAction,
markUpdateReopenedFormAction,
markUpdateResolvedFormAction,
} from '@/app/(campaign)/campanha/actions/municipalityUpdateDeliberation'
import {
MunicipalityUpdateDeliberation,
type MunicipalityUpdateDeliberationFormActions,
} from '@/components/campaign/municipality/MunicipalityUpdateDeliberation'
import { Badge } from '@/components/ui/Badge'
import {
municipalityUpdatePolarityBadgeVariant,
municipalityUpdatePolarityLabels,
} from '@/lib/schemas/municipalityUpdate'
import type { MunicipalityUpdateViewModel } from '@/utilities/municipality/municipalityUpdatePageData'
import type {
MunicipalityUpdateDeliberationContext,
MunicipalityUpdateViewModel,
} from '@/utilities/municipality/municipalityUpdatePageData'
const deliberationFormActions: MunicipalityUpdateDeliberationFormActions = {
assign: assignUpdateResponsibleFormAction,
comment: addUpdateCommentFormAction,
resolve: markUpdateResolvedFormAction,
reopen: markUpdateReopenedFormAction,
}
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'short',
@ -12,7 +32,13 @@ const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
const numberFormatter = new Intl.NumberFormat('pt-BR')
export const MunicipalityUpdateFeed = ({ updates }: { updates: MunicipalityUpdateViewModel[] }) => {
export const MunicipalityUpdateFeed = ({
updates,
deliberation,
}: {
updates: MunicipalityUpdateViewModel[]
deliberation: MunicipalityUpdateDeliberationContext
}) => {
if (!updates.length) {
return (
<p className="rounded-xl border px-4 py-6 text-sm text-muted-foreground">
@ -36,7 +62,13 @@ export const MunicipalityUpdateFeed = ({ updates }: { updates: MunicipalityUpdat
{dateTimeFormatter.format(new Date(update.createdAt))}
</span>
</div>
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
<p
className={
update.resolvedAt
? 'text-sm text-muted-foreground/70 whitespace-pre-wrap'
: 'text-sm text-muted-foreground whitespace-pre-wrap'
}
>
{update.body ?? 'Sem texto.'}
</p>
{update.activeVolunteers != null || update.newSupports != null ? (
@ -50,6 +82,19 @@ export const MunicipalityUpdateFeed = ({ updates }: { updates: MunicipalityUpdat
: null}
</p>
) : null}
{deliberation ? (
<MunicipalityUpdateDeliberation
updateId={update.id}
responsibleId={update.responsibleId}
responsibleName={update.responsibleName}
resolvedAt={update.resolvedAt}
resolvedByName={update.resolvedByName}
comments={update.comments}
eligibleStaff={deliberation.eligibleStaff}
capabilities={deliberation.capabilities}
formActions={deliberationFormActions}
/>
) : null}
</li>
))}
</ul>

View file

@ -47,3 +47,38 @@ export const municipalityUpdateCreateSchema = z.object({
})
export type MunicipalityUpdateCreateInput = z.input<typeof municipalityUpdateCreateSchema>
export const MUNICIPALITY_UPDATE_COMMENT_MAX_LENGTH = 4000
const MUNICIPALITY_UPDATE_COMMENT_REQUIRED_MESSAGE = 'Escreva o comentário antes de enviar.'
const MUNICIPALITY_UPDATE_COMMENT_TOO_LONG_MESSAGE = `O comentário deve ter no máximo ${MUNICIPALITY_UPDATE_COMMENT_MAX_LENGTH} caracteres.`
export const MUNICIPALITY_UPDATE_NO_MUNICIPALITY_MESSAGE =
'Atualização sem município não pode ter responsável.'
export const MUNICIPALITY_UPDATE_RESPONSIBLE_NOT_ELIGIBLE_MESSAGE =
'Este usuário não pode ser responsável: escolha um assessor do município ou a coordenação.'
export const municipalityUpdateCommentSchema = z.object({
updateId: positiveRelationshipId,
body: z
.string()
.trim()
.min(1, MUNICIPALITY_UPDATE_COMMENT_REQUIRED_MESSAGE)
.max(MUNICIPALITY_UPDATE_COMMENT_MAX_LENGTH, MUNICIPALITY_UPDATE_COMMENT_TOO_LONG_MESSAGE),
})
export const municipalityUpdateResponsibleSchema = z.object({
updateId: positiveRelationshipId,
/** `null` clears the assignee — every fact may also be ownerless. */
responsibleId: positiveRelationshipId.nullable(),
})
export const municipalityUpdateResolveSchema = z.object({
updateId: positiveRelationshipId,
})
export type MunicipalityUpdateResponsibleInput = z.input<typeof municipalityUpdateResponsibleSchema>
export type MunicipalityUpdateCommentInput = z.input<typeof municipalityUpdateCommentSchema>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,42 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "municipality_update_comments" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"body" varchar NOT NULL,
"author_id" integer,
"created_at" timestamp(3) with time zone
);
ALTER TABLE "municipality_update" ADD COLUMN "responsible_id" integer;
ALTER TABLE "municipality_update" ADD COLUMN "resolved_by_id" integer;
ALTER TABLE "municipality_update" ADD COLUMN "resolved_at" timestamp(3) with time zone;
ALTER TABLE "municipality_update_comments" ADD CONSTRAINT "municipality_update_comments_author_id_campaign_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "municipality_update_comments" ADD CONSTRAINT "municipality_update_comments_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."municipality_update"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "municipality_update_comments_order_idx" ON "municipality_update_comments" USING btree ("_order");
CREATE INDEX "municipality_update_comments_parent_id_idx" ON "municipality_update_comments" USING btree ("_parent_id");
CREATE INDEX "municipality_update_comments_author_idx" ON "municipality_update_comments" USING btree ("author_id");
ALTER TABLE "municipality_update" ADD CONSTRAINT "municipality_update_responsible_id_campaign_user_id_fk" FOREIGN KEY ("responsible_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "municipality_update" ADD CONSTRAINT "municipality_update_resolved_by_id_campaign_user_id_fk" FOREIGN KEY ("resolved_by_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "municipality_update_responsible_idx" ON "municipality_update" USING btree ("responsible_id");
CREATE INDEX "municipality_update_resolved_by_idx" ON "municipality_update" USING btree ("resolved_by_id");
CREATE INDEX "municipality_update_resolved_at_idx" ON "municipality_update" USING btree ("resolved_at");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "municipality_update_comments" CASCADE;
ALTER TABLE "municipality_update" DROP CONSTRAINT "municipality_update_responsible_id_campaign_user_id_fk";
ALTER TABLE "municipality_update" DROP CONSTRAINT "municipality_update_resolved_by_id_campaign_user_id_fk";
DROP INDEX "municipality_update_responsible_idx";
DROP INDEX "municipality_update_resolved_by_idx";
DROP INDEX "municipality_update_resolved_at_idx";
ALTER TABLE "municipality_update" DROP COLUMN "responsible_id";
ALTER TABLE "municipality_update" DROP COLUMN "resolved_by_id";
ALTER TABLE "municipality_update" DROP COLUMN "resolved_at";`)
}

View file

@ -61,6 +61,7 @@ import * as migration_20260819_213947_add_site_settings_facebook_pixel_id from '
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';
import * as migration_20260823_161905_add_municipality_update_deliberation from './20260823_161905_add_municipality_update_deliberation';
export const migrations = [
{
@ -378,4 +379,9 @@ export const migrations = [
down: migration_20260820_003426_add_instagram_sync_status.down,
name: '20260820_003426_add_instagram_sync_status',
},
{
up: migration_20260823_161905_add_municipality_update_deliberation.up,
down: migration_20260823_161905_add_municipality_update_deliberation.down,
name: '20260823_161905_add_municipality_update_deliberation'
},
];

View file

@ -885,6 +885,23 @@ export interface MunicipalityUpdate {
body: string;
activeVolunteers?: number | null;
newSupports?: number | null;
/**
* Staff do município ou coordenação que acompanha este fato.
*/
responsible?: (number | null) | CampaignUser;
resolvedBy?: (number | null) | CampaignUser;
resolvedAt?: string | null;
/**
* Fio de deliberação sobre esta atualização.
*/
comments?:
| {
body: string;
author?: (number | null) | CampaignUser;
createdAt?: string | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
@ -1861,6 +1878,17 @@ export interface MunicipalityUpdateSelect<T extends boolean = true> {
body?: T;
activeVolunteers?: T;
newSupports?: T;
responsible?: T;
resolvedBy?: T;
resolvedAt?: T;
comments?:
| T
| {
body?: T;
author?: T;
createdAt?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}

View file

@ -12,12 +12,18 @@ import {
isCampaignLeader,
isCampaignUnrestricted,
isPayloadAdmin,
payloadAdminOnly,
} from '@/utilities/access/shared'
export {
canMutateMunicipalityUpdate as canMutateAllocationDecision,
canReadMunicipalityUpdate as canReadAllocationDecision,
} from '@/utilities/access/municipalityUpdates'
export { canReadMunicipalityUpdate as canReadAllocationDecision } from '@/utilities/access/municipalityUpdates'
/**
* Allocation decisions are coordination records (E14 movements, triage
* outcomes): raw updates stay admin-only. Deliberate no longer aliases the
* municipality-update update gate — that gate is C88's deliberative
* `mutationKind` channel, which must never reach this collection.
*/
export const canMutateAllocationDecision: Access = payloadAdminOnly
/**
* C141 — allocation decisions record coordination movements (E14) and triage

View file

@ -2,12 +2,14 @@
// Municipality updates (immutable field reports)
// ---------------------------------------------------------------------------
import type { Access, FieldAccess } from 'payload'
import type { Access, FieldAccess, PayloadRequest, Where } from 'payload'
import { relationshipId } from '@/lib/relationship'
import type { CampaignUser } from '@/payload-types'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
advisorEditingAccess,
advisorMunicipalityScopeWhere,
getFreshCampaignUser,
isCampaignLeader,
isCampaignUnrestricted,
@ -15,6 +17,19 @@ import {
resolveProfileScopedRead,
} from '@/utilities/access/shared'
/**
* C88 — the only writes a staff campaign user may make to an update. Each kind
* narrows the row scope and the fields that may change (the collection's
* `beforeChange` allowlists them). Shared with the collection config so the
* gate and the hook never drift.
*/
export const MUNICIPALITY_UPDATE_DELIBERATION_MUTATIONS: ReadonlySet<string> = new Set([
'assignResponsible',
'appendComment',
'resolve',
'reopen',
])
export const canCreateMunicipalityUpdate: Access = async ({ data, req }) => {
if (isPayloadAdmin(req.user)) return true
@ -36,6 +51,128 @@ export const canCreateMunicipalityUpdate: Access = async ({ data, req }) => {
export const canReadMunicipalityUpdate: Access = ({ req }) =>
resolveProfileScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
export const canMutateMunicipalityUpdate: Access = ({ req }) => isPayloadAdmin(req.user)
const canDeliberateUnrestricted = async (req: PayloadRequest): Promise<boolean> => {
if (isPayloadAdmin(req.user)) return true
return isCampaignUnrestricted(await getFreshCampaignUser(req))
}
export const canSetMunicipalityUpdateAuthor: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
/**
* C88 — update gate by `context.mutationKind`. Out of the deliberative set
* nothing changes (admin keeps the raw update path); `assignResponsible`,
* `resolve` and `reopen` are coordinator/candidate (unrestricted) decisions;
* `appendComment` follows the create rule — whoever can register an update in
* that municipality may comment on it — expressed as a row `Where` so an
* advisor outside the portfolio cannot touch the row at all.
*/
export const canUpdateMunicipalityUpdate: Access = async ({ req }): Promise<boolean | Where> => {
if (isPayloadAdmin(req.user)) return true
const mutationKind = req.context?.mutationKind
if (
typeof mutationKind !== 'string' ||
!MUNICIPALITY_UPDATE_DELIBERATION_MUTATIONS.has(mutationKind)
) {
return false
}
const currentUser = await getFreshCampaignUser(req)
if (!currentUser || isCampaignLeader(currentUser)) return false
if (
mutationKind === 'assignResponsible' ||
mutationKind === 'resolve' ||
mutationKind === 'reopen'
) {
return isCampaignUnrestricted(currentUser)
}
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
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return advisorMunicipalityScopeWhere('municipality', municipalityIDs)
}
export const canDeleteMunicipalityUpdate: Access = ({ req }) => isPayloadAdmin(req.user)
/**
* C88 — field gate for `responsible` (assign/clear): coordinator/candidate only.
*/
export const canAssignUpdateResponsible: FieldAccess = ({ req }) => canDeliberateUnrestricted(req)
/**
* C88 — field gate for `resolvedBy`/`resolvedAt`: coordinator/candidate only.
*/
export const canResolveMunicipalityUpdate: FieldAccess = ({ req }) => canDeliberateUnrestricted(req)
/**
* C88 — field gate for `comments` (the append path): the same profile rule as
* `canCreateMunicipalityUpdate` without the per-row data — the collection
* update gate already scopes the row for advisors.
*/
export const canCommentOnMunicipalityUpdate: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser || isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser.role !== 'advisor') return false
return advisorEditingAccess(currentUser) !== 'none'
}
/** System-stamped comment fields (`author`/`createdAt`): admin only. */
export const canSetMunicipalityUpdateSystemField: FieldAccess = ({ req }) =>
isPayloadAdmin(req.user)
export const canSetMunicipalityUpdateAuthor: FieldAccess = canSetMunicipalityUpdateSystemField
export type MunicipalityUpdateDeliberationCapabilities = {
canAssign: boolean
canComment: boolean
canResolve: boolean
}
/**
* C88 — the ONLY spelling of "assignable staff": advisors of the given
* municipalities plus coordinator/candidate (unrestricted). The server action
* and the feed loader both build their `campaignUser` query from this fragment
* so the UI options and the save validation can never drift. Leaders are
* never assignable.
*/
export const assignableUpdateStaffWhere = (advisorIDs: readonly number[]): Where => ({
or: [
{ id: { in: [...advisorIDs] } },
{ role: { equals: 'coordinator' } },
{ role: { equals: 'candidate' } },
],
})
/**
* C88 — the actor's deliberation capabilities, mirroring the access predicates
* above: assign/resolve are coordinator/candidate decisions; commenting follows
* the create rule (staff who can register updates may comment). Pure — the
* server VM resolves it so the client never decides permission.
*/
export const resolveMunicipalityUpdateCapabilities = (
user: CampaignUser | null | undefined,
): MunicipalityUpdateDeliberationCapabilities => {
if (!user || isCampaignLeader(user)) {
return { canAssign: false, canComment: false, canResolve: false }
}
if (isCampaignUnrestricted(user)) {
return { canAssign: true, canComment: true, canResolve: true }
}
if (user.role === 'advisor') {
return {
canAssign: false,
canComment: advisorEditingAccess(user) !== 'none',
canResolve: false,
}
}
return { canAssign: false, canComment: false, canResolve: false }
}

View file

@ -72,10 +72,19 @@ export {
} from '@/utilities/access/allocationDecisions'
export {
MUNICIPALITY_UPDATE_DELIBERATION_MUTATIONS,
assignableUpdateStaffWhere,
canAssignUpdateResponsible,
canCommentOnMunicipalityUpdate,
canCreateMunicipalityUpdate,
canMutateMunicipalityUpdate,
canDeleteMunicipalityUpdate,
canReadMunicipalityUpdate,
canResolveMunicipalityUpdate,
canSetMunicipalityUpdateAuthor,
canSetMunicipalityUpdateSystemField,
canUpdateMunicipalityUpdate,
resolveMunicipalityUpdateCapabilities,
type MunicipalityUpdateDeliberationCapabilities,
} from '@/utilities/access/municipalityUpdates'
export {

View file

@ -18,6 +18,11 @@ import {
campaignUpdatesFeedPageSize,
type CampaignUpdatesFeedState,
} from '@/utilities/municipality/municipalityUpdateListUrl'
import {
loadMunicipalityUpdateDeliberationContext,
type MunicipalityUpdateDeliberationContext,
type MunicipalityUpdateViewModel,
} from '@/utilities/municipality/municipalityUpdatePageData'
export type CampaignUpdatesFeedCard = {
id: number
@ -34,6 +39,11 @@ export type CampaignUpdatesFeedCard = {
createdAt: string
author: { id: number; name: string; avatarUrl: string | null }
municipality: { id: number; name: string; slug: string }
/** C88 — deliberation state (responsible, thread, resolved) for the card. */
deliberation: Pick<
MunicipalityUpdateViewModel,
'responsibleId' | 'responsibleName' | 'resolvedAt' | 'resolvedByName' | 'comments'
>
}
export type CampaignUpdatesFeedFacets = {
@ -65,6 +75,7 @@ export const loadCampaignUpdatesFeed = async (
totalDocs: number
totalPages: number
page: number
deliberation: MunicipalityUpdateDeliberationContext
}> => {
const result = await payload.find({
collection: 'municipalityUpdate',
@ -79,18 +90,32 @@ export const loadCampaignUpdatesFeed = async (
const authorIDs = uniqueRelationshipIds(result.docs.map((update) => update.author))
const municipalityIDs = uniqueRelationshipIds(result.docs.map((update) => update.municipality))
const [authorDisplayById, municipalityLabelsById] = await Promise.all([
loadCampaignUserDisplayByIds(payload, authorIDs),
const deliberationUserIDs = new Set(authorIDs)
for (const doc of result.docs as MunicipalityUpdate[]) {
const responsibleID = relationshipId(doc.responsible)
if (responsibleID !== null) deliberationUserIDs.add(responsibleID)
const resolvedByID = relationshipId(doc.resolvedBy)
if (resolvedByID !== null) deliberationUserIDs.add(resolvedByID)
for (const comment of doc.comments ?? []) {
const commentAuthorID = relationshipId(comment.author)
if (commentAuthorID !== null) deliberationUserIDs.add(commentAuthorID)
}
}
const [userDisplayById, municipalityLabelsById, deliberation] = await Promise.all([
loadCampaignUserDisplayByIds(payload, [...deliberationUserIDs]),
loadMunicipalityLabelsByIds(payload, municipalityIDs),
loadMunicipalityUpdateDeliberationContext(payload, user, municipalityIDs),
])
const cards = result.docs.map((update): CampaignUpdatesFeedCard => {
const doc = update as MunicipalityUpdate
const authorID = relationshipId(doc.author) ?? 0
const municipalityID = relationshipId(doc.municipality) ?? 0
const author = authorDisplayById.get(authorID)
const author = userDisplayById.get(authorID)
const municipality = municipalityLabelsById.get(municipalityID)
const polarity = doc.polarity as MunicipalityUpdatePolarity | undefined
const responsibleID = relationshipId(doc.responsible)
const resolvedByID = relationshipId(doc.resolvedBy)
return {
id: doc.id,
@ -107,6 +132,20 @@ export const loadCampaignUpdatesFeed = async (
municipality: municipality
? { id: municipalityID, name: municipality.name, slug: municipality.slug }
: { id: municipalityID, name: 'Município', slug: '' },
deliberation: {
responsibleId: responsibleID,
responsibleName:
responsibleID === null ? null : (userDisplayById.get(responsibleID)?.name ?? 'Usuário'),
resolvedAt: doc.resolvedAt ?? null,
resolvedByName:
resolvedByID === null ? null : (userDisplayById.get(resolvedByID)?.name ?? 'Usuário'),
comments: (doc.comments ?? []).map((comment) => ({
id: comment.id ?? null,
authorName: userDisplayById.get(relationshipId(comment.author) ?? -1)?.name ?? 'Usuário',
createdAt: comment.createdAt ?? null,
body: comment.body,
})),
},
}
})
@ -115,6 +154,7 @@ export const loadCampaignUpdatesFeed = async (
totalDocs: result.totalDocs,
totalPages: result.totalPages,
page: result.page ?? state.page,
deliberation,
}
}

View file

@ -28,6 +28,7 @@ import {
} from '@/utilities/municipality/municipalityGoalAccount'
import {
loadMunicipalityUpdatesFeed,
type MunicipalityUpdateDeliberationContext,
type MunicipalityUpdateViewModel,
} from '@/utilities/municipality/municipalityUpdatePageData'
import type { MunicipalityDetailViewModel } from '@/utilities/municipality/municipalityViewModels'
@ -51,7 +52,11 @@ export type MunicipalityDossierData = {
goalAccount: MunicipalityGoalAccount | null
pledgeAggregate: MunicipalityPledgeAggregate
leaderships: { rows: LeadershipRowViewModel[]; totalCount: number }
signals: { rows: MunicipalityUpdateViewModel[]; totalCount: number }
signals: {
rows: MunicipalityUpdateViewModel[]
totalCount: number
deliberation: MunicipalityUpdateDeliberationContext
}
upcomingActivities: ActivityListViewModel[]
recentActivities: ActivityListViewModel[]
demographics: MunicipalityDemographics | null
@ -122,7 +127,11 @@ export const loadMunicipalityDossierData = async (
goalAccount: pledgesAndGoal.goalAccount,
pledgeAggregate: pledgesAndGoal.pledgeAggregate,
leaderships,
signals: { rows: feed.updates.slice(0, DOSSIER_SIGNAL_LIMIT), totalCount: feed.totalDocs },
signals: {
rows: feed.updates.slice(0, DOSSIER_SIGNAL_LIMIT),
totalCount: feed.totalDocs,
deliberation: feed.deliberation,
},
upcomingActivities: activities.upcoming,
recentActivities: activities.recent,
demographics: demographicsForCode(view.ibgeCode) ?? null,

View file

@ -2,16 +2,28 @@ import 'server-only'
import type { Payload } from 'payload'
import { relationshipId } from '@/lib/relationship'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import {
municipalityUpdatePolarities,
type MunicipalityUpdatePolarity,
} from '@/lib/schemas/municipalityUpdate'
import type { CampaignUser, MunicipalityUpdate } from '@/payload-types'
import {
assignableUpdateStaffWhere,
resolveMunicipalityUpdateCapabilities,
type MunicipalityUpdateDeliberationCapabilities,
} from '@/utilities/campaignAccess'
import { loadCampaignUserNamesByIds } from '@/utilities/loadNamesByIds'
export const municipalityUpdatesPageSize = 10
export type MunicipalityUpdateCommentViewModel = {
id: string | null
authorName: string
createdAt: string | null
body: string
}
export type MunicipalityUpdateViewModel = {
id: number
authorName: string
@ -22,6 +34,23 @@ export type MunicipalityUpdateViewModel = {
activeVolunteers: number | null
newSupports: number | null
adversarySignal: boolean
responsibleId: number | null
responsibleName: string | null
resolvedAt: string | null
resolvedByName: string | null
comments: MunicipalityUpdateCommentViewModel[]
}
export type EligibleUpdateStaffMember = {
id: number
name: string
/** Assignable roles only — `assignableUpdateStaffWhere` never returns a leader. */
role: 'coordinator' | 'advisor' | 'candidate'
}
export type MunicipalityUpdateDeliberationContext = {
eligibleStaff: EligibleUpdateStaffMember[]
capabilities: MunicipalityUpdateDeliberationCapabilities
}
export type MunicipalityUpdateFeedState = {
@ -41,12 +70,14 @@ export const parseMunicipalityUpdateFeedParams = (
/** One doc → view-model mapping, shared by the feed and the list batch (B193). */
const toMunicipalityUpdateViewModel = (
update: MunicipalityUpdate,
authorNameById: ReadonlyMap<number, string>,
namesById: ReadonlyMap<number, string>,
): MunicipalityUpdateViewModel => {
const polarity = update.polarity as MunicipalityUpdatePolarity | undefined
const responsibleId = relationshipId(update.responsible)
const resolvedById = relationshipId(update.resolvedBy)
return {
id: update.id,
authorName: authorNameById.get(relationshipId(update.author) ?? -1) ?? 'Usuário',
authorName: namesById.get(relationshipId(update.author) ?? -1) ?? 'Usuário',
createdAt: update.createdAt,
body: update.body ?? null,
polarity: polarity && municipalityUpdatePolarities.includes(polarity) ? polarity : 'neutra',
@ -54,6 +85,16 @@ const toMunicipalityUpdateViewModel = (
activeVolunteers: update.activeVolunteers ?? null,
newSupports: update.newSupports ?? null,
adversarySignal: Boolean(update.adversarySignal),
responsibleId,
responsibleName: responsibleId === null ? null : (namesById.get(responsibleId) ?? 'Usuário'),
resolvedAt: update.resolvedAt ?? null,
resolvedByName: resolvedById === null ? null : (namesById.get(resolvedById) ?? 'Usuário'),
comments: (update.comments ?? []).map((comment) => ({
id: comment.id ?? null,
authorName: namesById.get(relationshipId(comment.author) ?? -1) ?? 'Usuário',
createdAt: comment.createdAt ?? null,
body: comment.body,
})),
}
}
@ -61,14 +102,86 @@ const loadUpdateAuthorNames = async (
payload: Payload,
updates: MunicipalityUpdate[],
): Promise<ReadonlyMap<number, string>> => {
const authorIDs = [
...new Set(
updates
.map((update) => relationshipId(update.author))
.filter((id): id is number => id !== null),
),
]
return loadCampaignUserNamesByIds(payload, authorIDs)
const authorIDs = new Set<number>()
for (const update of updates) {
const authorID = relationshipId(update.author)
if (authorID !== null) authorIDs.add(authorID)
const responsibleID = relationshipId(update.responsible)
if (responsibleID !== null) authorIDs.add(responsibleID)
const resolvedByID = relationshipId(update.resolvedBy)
if (resolvedByID !== null) authorIDs.add(resolvedByID)
for (const comment of update.comments ?? []) {
const commentAuthorID = relationshipId(comment.author)
if (commentAuthorID !== null) authorIDs.add(commentAuthorID)
}
}
return loadCampaignUserNamesByIds(payload, [...authorIDs])
}
/**
* C88 — staff eligible to be assigned as responsible: advisors of the
* municipalities on the page plus coordinator/candidate (unrestricted), the
* same rule the server action enforces. Scoped reads (`overrideAccess: false`)
* keep the advisor's portfolio: municipalities outside it return no rows, and
* campaign users are readable by any campaign user.
*/
const loadMunicipalityUpdateEligibleStaff = async (
payload: Payload,
user: CampaignUser,
municipalityIDs: readonly number[],
): Promise<EligibleUpdateStaffMember[]> => {
const ids = [...new Set(municipalityIDs)]
if (ids.length === 0) return []
const municipalities = await payload.find({
collection: 'municipality',
where: { id: { in: ids } },
depth: 0,
limit: 0,
pagination: false,
select: { advisors: true },
user,
overrideAccess: false,
})
const advisorIDs = uniqueRelationshipIds(
municipalities.docs.flatMap((municipality) => municipality.advisors),
)
const staff = await payload.find({
collection: 'campaignUser',
where: assignableUpdateStaffWhere(advisorIDs),
depth: 0,
limit: 0,
pagination: false,
select: { id: true, name: true, role: true },
user,
overrideAccess: false,
})
return staff.docs
.map((doc) => ({
id: doc.id,
name: doc.name,
role: doc.role as EligibleUpdateStaffMember['role'],
}))
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
}
/**
* C88 — deliberation capabilities + eligible staff for the municipalities a
* feed page renders. The staff list is only loaded when the actor can assign
* (coordinator/candidate) — advisors comment but never assign.
*/
export const loadMunicipalityUpdateDeliberationContext = async (
payload: Payload,
user: CampaignUser,
municipalityIDs: readonly number[],
): Promise<MunicipalityUpdateDeliberationContext> => {
const capabilities = resolveMunicipalityUpdateCapabilities(user)
const eligibleStaff = capabilities.canAssign
? await loadMunicipalityUpdateEligibleStaff(payload, user, municipalityIDs)
: []
return { eligibleStaff, capabilities }
}
export const loadMunicipalityUpdatesFeed = async (
@ -81,6 +194,7 @@ export const loadMunicipalityUpdatesFeed = async (
totalDocs: number
totalPages: number
page: number
deliberation: MunicipalityUpdateDeliberationContext
}> => {
const result = await payload.find({
collection: 'municipalityUpdate',
@ -96,13 +210,17 @@ export const loadMunicipalityUpdatesFeed = async (
})
const docs = result.docs as MunicipalityUpdate[]
const authorNameById = await loadUpdateAuthorNames(payload, docs)
const [namesById, deliberation] = await Promise.all([
loadUpdateAuthorNames(payload, docs),
loadMunicipalityUpdateDeliberationContext(payload, user, [municipalityID]),
])
return {
updates: docs.map((update) => toMunicipalityUpdateViewModel(update, authorNameById)),
updates: docs.map((update) => toMunicipalityUpdateViewModel(update, namesById)),
totalDocs: result.totalDocs,
totalPages: result.totalPages,
page: result.page ?? state.page,
deliberation,
}
}
@ -110,11 +228,13 @@ export const loadMunicipalityUpdatesPreview = async (
payload: Payload,
user: CampaignUser,
municipalityID: number,
): Promise<MunicipalityUpdateViewModel[]> =>
(await loadMunicipalityUpdatesFeed(payload, user, municipalityID, { page: 1 })).updates.slice(
0,
3,
)
): Promise<{
updates: MunicipalityUpdateViewModel[]
deliberation: MunicipalityUpdateDeliberationContext
}> => {
const feed = await loadMunicipalityUpdatesFeed(payload, user, municipalityID, { page: 1 })
return { updates: feed.updates.slice(0, 3), deliberation: feed.deliberation }
}
/**
* B193 — the LAST update per listed municipality, for the mobile card's

View file

@ -0,0 +1,161 @@
import type { Page } from '@playwright/test'
import type { CampaignUser } from '../../src/payload-types.js'
import {
expect,
mintCampaignSession,
seedCampaignSession,
test,
type CampaignE2EFixture,
} from './fixtures/campaignE2EFixtures.js'
const DESKTOP_VIEWPORT = { width: 1280, height: 900 }
const settleStream = (page: Page) =>
page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0, undefined, {
timeout: 15_000,
})
type DeliberationSeed = {
coordinator: CampaignUser & { password: string }
advisor: CampaignUser & { password: string }
updateId: number
/** Unique per test — parallel workers share the feed page (fullyParallel). */
marker: string
}
const seedDeliberation = async (campaign: CampaignE2EFixture): Promise<DeliberationSeed> => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.claimMunicipality()
await fixtures.payload.update({
collection: 'municipality',
id: municipality.id,
data: { advisors: [advisor.id] },
depth: 0,
})
const marker = fixtures.value('deliberado')
const update = await fixtures.payload.create({
collection: 'municipalityUpdate',
data: {
municipality: municipality.id,
author: coordinator.id,
polarity: 'ruim',
urgent: true,
body: `Fato ${marker} em ${municipality.name}`,
},
depth: 0,
})
return { coordinator, advisor, updateId: update.id, marker }
}
const openFeedAs = async (
campaign: CampaignE2EFixture,
context: Parameters<typeof seedCampaignSession>[0],
user: CampaignUser & { password: string },
page: Page,
) => {
const token = await mintCampaignSession(campaign.payload, user)
await seedCampaignSession(context, campaign.baseURL, token)
await page.setViewportSize(DESKTOP_VIEWPORT)
await page.goto(`${campaign.baseURL}/campanha/atualizacoes`)
await settleStream(page)
}
test.describe('C88 — deliberação na atualização', () => {
test.setTimeout(120_000)
test('coordinator assigns, comments, resolves and reopens; advisor only comments', async ({
campaign,
context,
page,
}) => {
const { coordinator, advisor, updateId, marker } = await seedDeliberation(campaign)
const card = page.locator(`li:has-text("Fato ${marker}")`).first()
await openFeedAs(campaign, context, coordinator, page)
await expect(card.getByText('Em deliberação')).toBeHidden()
// Assign the advisor as responsible.
await card.getByLabel('Responsável').selectOption(String(advisor.id))
await card.getByRole('button', { name: 'Definir' }).click()
await expect(card.getByText('Responsável definido.')).toBeVisible()
await expect(card.getByText('Em deliberação')).toBeVisible()
// Comment on the thread.
await card
.getByRole('textbox', { name: 'Escrever comentário…' })
.fill('Confirmo a visita para sexta.')
await card.getByRole('button', { name: 'Comentar' }).click()
await expect(card.getByText('Comentário registrado.')).toBeVisible()
await expect(card.getByText('Confirmo a visita para sexta.')).toBeVisible()
// Resolve: badge flips, thread stays readable, composer hides, Reabrir shows.
await card.getByRole('button', { name: 'Marcar como resolvido' }).click()
await expect(card.getByText('Atualização marcada como resolvida.')).toBeVisible()
await expect(card.getByText('Resolvido', { exact: true })).toBeVisible()
await expect(card.getByText(/Resolvido por/)).toBeVisible()
await expect(card.getByText('Confirmo a visita para sexta.')).toBeVisible()
await expect(card.getByRole('textbox', { name: 'Escrever comentário…' })).toHaveCount(0)
await expect(card.getByRole('button', { name: 'Reabrir' })).toBeVisible()
// Reopen: back to open deliberation.
await card.getByRole('button', { name: 'Reabrir' }).click()
await expect(card.getByText('Atualização reaberta.')).toBeVisible()
await expect(card.getByText('Em deliberação')).toBeVisible()
// Advisor: read-only on assign/resolve, comments as themselves.
const advisorPage = await context.newPage()
await openFeedAs(campaign, context, advisor, advisorPage)
const advisorCard = advisorPage.locator(`li:has-text("Fato ${marker}")`).first()
await expect(advisorCard.getByText('Em deliberação')).toBeVisible()
await expect(advisorCard.getByText(`Responsável: ${advisor.name}`)).toBeVisible()
await expect(advisorCard.getByLabel('Responsável')).toHaveCount(0)
await expect(advisorCard.getByRole('button', { name: 'Marcar como resolvido' })).toHaveCount(0)
await advisorCard
.getByRole('textbox', { name: 'Escrever comentário…' })
.fill('Vou verificar no território.')
await advisorCard.getByRole('button', { name: 'Comentar' }).click()
await expect(advisorCard.getByText('Vou verificar no território.').first()).toBeVisible()
await advisorPage.close()
// The server enforced everything: the update is still open with both comments.
const reloaded = await campaign.payload.findByID({
collection: 'municipalityUpdate',
id: updateId,
depth: 0,
overrideAccess: true,
})
expect(reloaded.resolvedAt).toBeNull()
expect(reloaded.comments?.map((comment) => comment.body)).toEqual([
'Confirmo a visita para sexta.',
'Vou verificar no território.',
])
})
test('leader never reaches the thread (lockdown keeps the feed page away)', async ({
campaign,
context,
page,
}) => {
const { coordinator } = await seedDeliberation(campaign)
const leader = await campaign.fixtures.createCampaignUser('leader')
const municipality = await campaign.fixtures.claimMunicipality()
await campaign.fixtures.payload.create({
collection: 'municipalityUpdate',
data: {
municipality: municipality.id,
author: coordinator.id,
polarity: 'neutra',
body: 'Fato C88 para a liderança não ver.',
},
depth: 0,
})
const token = await mintCampaignSession(campaign.payload, leader)
await seedCampaignSession(context, campaign.baseURL, token)
await page.goto(`${campaign.baseURL}/campanha/atualizacoes`)
await expect(page).toHaveURL(/\/campanha\/meus-contatos/, { timeout: 20_000 })
})
})

View file

@ -73,7 +73,10 @@ test.describe('C106 — atualizações mobile sem moldura', () => {
await expect(strip).toHaveCSS('position', 'sticky')
await expect(strip).toHaveCSS('top', '0px')
await expect(strip).toHaveCSS('border-bottom-width', '1px')
await expect(page.getByRole('combobox').locator('xpath=..')).toHaveCSS(
// C88 — scoped to the filter strip: each update card may now carry its own
// combobox (the deliberation assignee select), so a page-wide locator is
// ambiguous.
await expect(strip.getByRole('combobox').locator('xpath=..')).toHaveCSS(
'border-top-width',
'0px',
)

View file

@ -0,0 +1,235 @@
// @vitest-environment node
import { getPayload, type Payload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import {
addMunicipalityUpdateCommentRecord,
assignMunicipalityUpdateResponsibleRecord,
markMunicipalityUpdateReopenedRecord,
markMunicipalityUpdateResolvedRecord,
} from '@/app/(campaign)/campanha/actions/municipalityUpdateDeliberation'
import { relationshipId } from '@/lib/relationship'
import { MUNICIPALITY_UPDATE_RESPONSIBLE_NOT_ELIGIBLE_MESSAGE } from '@/lib/schemas/municipalityUpdate'
import type { MunicipalityUpdate } from '@/payload-types'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const loadUpdate = async (updateId: number): Promise<MunicipalityUpdate> =>
payload.findByID({
collection: 'municipalityUpdate',
id: updateId,
depth: 0,
overrideAccess: true,
})
describe('municipality update deliberation', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
const createDeliberationContext = async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const advisor = await campaignFixtures().createCampaignUser('advisor')
const outsiderAdvisor = await campaignFixtures().createCampaignUser('advisor')
const leader = await campaignFixtures().createCampaignUser('leader')
const municipality = await campaignFixtures().getMunicipality()
const otherMunicipality = await campaignFixtures().getMunicipality()
await campaignFixtures().assignMunicipalityAdvisors(municipality, [advisor.id])
await campaignFixtures().assignMunicipalityAdvisors(otherMunicipality, [outsiderAdvisor.id])
const update = await campaignFixtures().createMunicipalityUpdate({
municipality: municipality.id,
author: coordinator.id,
polarity: 'ruim',
body: 'Fato registrado pela coordenação.',
})
return { coordinator, advisor, outsiderAdvisor, leader, municipality, update }
}
it('coordinator assigns an advisor of the municipality and clears the assignee', async () => {
const { coordinator, advisor, update } = await createDeliberationContext()
const assigned = await assignMunicipalityUpdateResponsibleRecord(payload, coordinator, {
updateId: update.id,
responsibleId: advisor.id,
})
expect(relationshipId(assigned.responsible)).toBe(advisor.id)
const cleared = await assignMunicipalityUpdateResponsibleRecord(payload, coordinator, {
updateId: update.id,
responsibleId: null,
})
expect(cleared.responsible).toBeNull()
})
it('coordinator may assign another coordinator or the candidate', async () => {
const { coordinator, municipality } = await createDeliberationContext()
const candidate = await campaignFixtures().createCampaignUser('candidate')
const update = await campaignFixtures().createMunicipalityUpdate({
municipality: municipality.id,
author: coordinator.id,
polarity: 'neutra',
body: 'Fato para o candidato.',
})
const assigned = await assignMunicipalityUpdateResponsibleRecord(payload, coordinator, {
updateId: update.id,
responsibleId: candidate.id,
})
expect(relationshipId(assigned.responsible)).toBe(candidate.id)
})
it('rejects a leader as responsible', async () => {
const { coordinator, leader, update } = await createDeliberationContext()
await expect(
assignMunicipalityUpdateResponsibleRecord(payload, coordinator, {
updateId: update.id,
responsibleId: leader.id,
}),
).rejects.toThrow(MUNICIPALITY_UPDATE_RESPONSIBLE_NOT_ELIGIBLE_MESSAGE)
})
it('rejects an advisor outside the municipality as responsible', async () => {
const { coordinator, outsiderAdvisor, update } = await createDeliberationContext()
await expect(
assignMunicipalityUpdateResponsibleRecord(payload, coordinator, {
updateId: update.id,
responsibleId: outsiderAdvisor.id,
}),
).rejects.toThrow(MUNICIPALITY_UPDATE_RESPONSIBLE_NOT_ELIGIBLE_MESSAGE)
})
it('advisor of the municipality comments and the hook stamps author and createdAt', async () => {
const { advisor, update } = await createDeliberationContext()
await addMunicipalityUpdateCommentRecord(payload, advisor, {
updateId: update.id,
body: ' Vou verificar no território. ',
})
const reloaded = await loadUpdate(update.id)
expect(reloaded.comments).toHaveLength(1)
expect(reloaded.comments?.[0]?.body).toBe('Vou verificar no território.')
expect(relationshipId(reloaded.comments?.[0]?.author)).toBe(advisor.id)
expect(reloaded.comments?.[0]?.createdAt).toBeTruthy()
expect(reloaded.body).toBe('Fato registrado pela coordenação.')
})
it('appends comments in order without touching earlier ones', async () => {
const { advisor, update } = await createDeliberationContext()
await addMunicipalityUpdateCommentRecord(payload, advisor, {
updateId: update.id,
body: 'Primeiro.',
})
await addMunicipalityUpdateCommentRecord(payload, advisor, {
updateId: update.id,
body: 'Segundo.',
})
const reloaded = await loadUpdate(update.id)
expect(reloaded.comments?.map((comment) => comment.body)).toEqual(['Primeiro.', 'Segundo.'])
})
it('denies commenting to an advisor outside the portfolio', async () => {
const { outsiderAdvisor, update } = await createDeliberationContext()
// The scoped read finds no row: the outsider cannot even see the update
// (fail-closed 404, same as the create gate).
await expect(
addMunicipalityUpdateCommentRecord(payload, outsiderAdvisor, {
updateId: update.id,
body: 'Fora do meu município.',
}),
).rejects.toThrow(/não encontrado/i)
})
it('denies assigning and resolving to advisors and leaders', async () => {
const { advisor, leader, update } = await createDeliberationContext()
await expect(
assignMunicipalityUpdateResponsibleRecord(payload, advisor, {
updateId: update.id,
responsibleId: advisor.id,
}),
).rejects.toThrow(/permissão/i)
await expect(
markMunicipalityUpdateResolvedRecord(payload, advisor, { updateId: update.id }),
).rejects.toThrow(/permissão/i)
await expect(
addMunicipalityUpdateCommentRecord(payload, leader, {
updateId: update.id,
body: 'Liderança não participa.',
}),
).rejects.toThrow(/permissão/i)
})
it('coordinator resolves and reopens, stamping who and when', async () => {
const { coordinator, advisor, update } = await createDeliberationContext()
await assignMunicipalityUpdateResponsibleRecord(payload, coordinator, {
updateId: update.id,
responsibleId: advisor.id,
})
await addMunicipalityUpdateCommentRecord(payload, advisor, {
updateId: update.id,
body: 'Resolvido no território.',
})
const resolved = await markMunicipalityUpdateResolvedRecord(payload, coordinator, {
updateId: update.id,
})
expect(resolved.resolvedAt).toBeTruthy()
expect(relationshipId(resolved.resolvedBy)).toBe(coordinator.id)
expect(resolved.comments).toHaveLength(1)
const reopened = await markMunicipalityUpdateReopenedRecord(payload, coordinator, {
updateId: update.id,
})
expect(reopened.resolvedAt).toBeNull()
expect(reopened.resolvedBy).toBeNull()
expect(reopened.comments).toHaveLength(1)
})
it('deliberative writes cannot touch the fact body (allowlist)', async () => {
const { coordinator, update } = await createDeliberationContext()
await expect(
payload.update({
collection: 'municipalityUpdate',
id: update.id,
data: { body: 'Reescrito pela deliberação.' },
depth: 0,
user: coordinator,
overrideAccess: false,
context: { mutationKind: 'appendComment' },
}),
).rejects.toThrow('Esta atualização não pode ser alterada por deliberação.')
})
it('rejects a leader who reads the update (lockdown at the root)', async () => {
const { leader, update } = await createDeliberationContext()
await expect(
payload.findByID({
collection: 'municipalityUpdate',
id: update.id,
depth: 0,
user: leader,
overrideAccess: false,
}),
).rejects.toThrow(/permissão/i)
})
})

View file

@ -171,8 +171,8 @@ describe('campaign municipality update page data queries', () => {
}
const preview = await loadMunicipalityUpdatesPreview(payload, user, municipality.id)
expect(preview).toHaveLength(3)
expect(preview[0]?.body).toBe('Prévia 3')
expect(preview.updates).toHaveLength(3)
expect(preview.updates[0]?.body).toBe('Prévia 3')
})
it('denies leaders read access to municipality update feeds', async () => {

View file

@ -579,6 +579,11 @@ describe('campaign visual foundation', () => {
activeVolunteers: null,
newSupports: null,
adversarySignal: false,
responsibleId: null,
responsibleName: null,
resolvedAt: null,
resolvedByName: null,
comments: [],
},
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,

View file

@ -0,0 +1,81 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import type { CampaignUser } from '@/payload-types'
import {
MUNICIPALITY_UPDATE_DELIBERATION_MUTATIONS,
resolveMunicipalityUpdateCapabilities,
} from '@/utilities/access/municipalityUpdates'
import { stub } from '../helpers/stub'
const campaignUser = (role: CampaignUser['role'], extra: Partial<CampaignUser> = {}) =>
stub<CampaignUser>({ collection: 'campaignUser', id: 1, role, ...extra })
/**
* C88 — the capability resolver is the pure mirror of the access predicates
* (assign/resolve = unrestricted, comment = create rule); the int spec pins
* the end-to-end gate. These pin the profile matrix the client card renders.
*/
describe('municipality update deliberation capabilities (C88)', () => {
it('grants everything to coordinator and candidate', () => {
expect(resolveMunicipalityUpdateCapabilities(campaignUser('coordinator'))).toEqual({
canAssign: true,
canComment: true,
canResolve: true,
})
expect(resolveMunicipalityUpdateCapabilities(campaignUser('candidate'))).toEqual({
canAssign: true,
canComment: true,
canResolve: true,
})
})
it('advisor may comment unless the Edição axis is somente_leitura, never assign or resolve', () => {
expect(
resolveMunicipalityUpdateCapabilities(campaignUser('advisor', { editing: 'carteira' })),
).toEqual({ canAssign: false, canComment: true, canResolve: false })
expect(
resolveMunicipalityUpdateCapabilities(
campaignUser('advisor', { visibility: 'tudo', editing: 'tudo' }),
),
).toEqual({ canAssign: false, canComment: true, canResolve: false })
// C141 coherence rule: Edição "Tudo" sem Visão "Tudo" resolve "none".
expect(
resolveMunicipalityUpdateCapabilities(campaignUser('advisor', { editing: 'tudo' })),
).toEqual({ canAssign: false, canComment: false, canResolve: false })
expect(
resolveMunicipalityUpdateCapabilities(
campaignUser('advisor', { editing: 'somente_leitura' }),
),
).toEqual({ canAssign: false, canComment: false, canResolve: false })
})
it('leader, unknown actors and anonymous get nothing', () => {
expect(resolveMunicipalityUpdateCapabilities(campaignUser('leader'))).toEqual({
canAssign: false,
canComment: false,
canResolve: false,
})
expect(resolveMunicipalityUpdateCapabilities(null)).toEqual({
canAssign: false,
canComment: false,
canResolve: false,
})
expect(resolveMunicipalityUpdateCapabilities(undefined)).toEqual({
canAssign: false,
canComment: false,
canResolve: false,
})
})
it('exposes exactly the four deliberative mutation kinds the hook allowlists', () => {
expect([...MUNICIPALITY_UPDATE_DELIBERATION_MUTATIONS]).toEqual([
'assignResponsible',
'appendComment',
'resolve',
'reopen',
])
})
})