diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index c03a8248..09cb8f3f 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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 `
`: 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. diff --git a/docs/changelog/2026-08-23-c88.md b/docs/changelog/2026-08-23-c88.md new file mode 100644 index 00000000..53ce72cf --- /dev/null +++ b/docs/changelog/2026-08-23-c88.md @@ -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. diff --git a/docs/plans/deliberacao-atualizacao-responsavel-thread-impl.md b/docs/plans/deliberacao-atualizacao-responsavel-thread-impl.md new file mode 100644 index 00000000..253bbbf2 --- /dev/null +++ b/docs/plans/deliberacao-atualizacao-responsavel-thread-impl.md @@ -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
(select responsável · composer · Resolvido/Reabrir)"] + A["Server actions (zod + runCampaignFormAction)
transaction + advisory lock por doc"] + U["payload.update
context.mutationKind = deliberação"] + G["Collection access: gate por mutationKind
+ FieldAccess por campo"] + H["beforeChange derivador: carimba
comentários novos, barra campos staff"] + VM["loadMunicipalityUpdatesFeed: VM + staff elegível
+ 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:` na action, ANTES do `payload.update`; o `beforeChange` mantém o lock por município existente `municipality-updates:` 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:`). 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__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:'])` → `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 " + 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:` (doc) ANTES do update; o `beforeChange` adquire `municipality-updates:` (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). diff --git a/src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx b/src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx index 5f9cb462..aa6c5e96 100644 --- a/src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx @@ -54,7 +54,7 @@ export default async function CampaignUpdatesPage({ searchParams }: CampaignUpda - + )} - + [`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 => { + 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 => + 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 => + 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 => + 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 => + 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.', + }) diff --git a/src/collections/MunicipalityUpdate.ts b/src/collections/MunicipalityUpdate.ts index 564d4435..348b4a5d 100644 --- a/src/collections/MunicipalityUpdate.ts +++ b/src/collections/MunicipalityUpdate.ts @@ -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 + 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, 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, + }, + }, + ], + }, ], } diff --git a/src/components/campaign/municipality/CampaignUpdatesFeed.tsx b/src/components/campaign/municipality/CampaignUpdatesFeed.tsx index 98979e88..e16942c2 100644 --- a/src/components/campaign/municipality/CampaignUpdatesFeed.tsx +++ b/src/components/campaign/municipality/CampaignUpdatesFeed.tsx @@ -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 +}) => (
  • className="mt-0.5 size-10 shrink-0" />
    -

    {card.body ?? 'Sem texto.'}

    +

    {card.body ?? 'Sem texto.'}

    {municipalityUpdatePolarityLabels[card.polarity]} @@ -38,11 +62,28 @@ const CampaignUpdatesFeedItem = ({ card }: { card: CampaignUpdatesFeedCard }) => {dateTimeFormatter.format(new Date(card.createdAt))}

    +
  • ) -export const CampaignUpdatesFeed = ({ cards }: { cards: CampaignUpdatesFeedCard[] }) => { +export const CampaignUpdatesFeed = ({ + cards, + deliberation, +}: { + cards: CampaignUpdatesFeedCard[] + deliberation: MunicipalityUpdateDeliberationContext +}) => { if (cards.length === 0) { return ( {cards.map((card) => ( - + ))} ) diff --git a/src/components/campaign/municipality/MunicipalityDossier.tsx b/src/components/campaign/municipality/MunicipalityDossier.tsx index 1eee5a55..10c0a4ab 100644 --- a/src/components/campaign/municipality/MunicipalityDossier.tsx +++ b/src/components/campaign/municipality/MunicipalityDossier.tsx @@ -336,7 +336,10 @@ export const MunicipalityDossier = ({ title={`Sinais e atualizações recentes (${formatElectionNumber(data.signals.totalCount)})`} seeAllHref={`/campanha/municipios/${view.slug}?tab=updates`} /> - + {/* Perfil do eleitorado — IBGE Censo 2022 (A8). */} diff --git a/src/components/campaign/municipality/MunicipalityUpdateDeliberation.tsx b/src/components/campaign/municipality/MunicipalityUpdateDeliberation.tsx new file mode 100644 index 00000000..bf47d08c --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityUpdateDeliberation.tsx @@ -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 + comment: (state: CampaignFormActionState, formData: FormData) => Promise + resolve: (state: CampaignFormActionState, formData: FormData) => Promise + reopen: (state: CampaignFormActionState, formData: FormData) => Promise +} + +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 ( +
    + {isResolved || inDeliberation ? ( +
    + {isResolved ? ( + Resolvido + ) : ( + Em deliberação + )} +
    + ) : null} + + {isResolved ? ( +

    + Resolvido por {resolvedByName} + {resolvedAt ? ` em ${dateTimeFormatter.format(new Date(resolvedAt))}` : ''} +

    + ) : null} + + {capabilities.canAssign || responsibleId !== null ? ( + + ) : null} + + {comments.length > 0 || capabilities.canComment ? ( + + ) : null} + + {capabilities.canResolve ? ( +
    + +
    + ) : null} +
    + ) +} + +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 ( +

    + Responsável:{' '} + {responsibleName} +

    + ) + } + + return ( + + + + + Sem responsável + {eligibleStaff.map((staff) => ( + + {staff.name} ({staffRoleLabels[staff.role]}) + + ))} + + + {state.message ? ( + + ) : null} + + ) +} + +const CommentThread = ({ + updateId, + comments, + canComment, + formAction, +}: { + updateId: number + comments: MunicipalityUpdateCommentViewModel[] + canComment: boolean + formAction: MunicipalityUpdateDeliberationFormActions['comment'] +}) => { + const [state, submitAction, isPending] = useActionState(formAction, {}) + const formRef = useRef(null) + + useEffect(() => { + if (state.status === 'success' && formRef.current) formRef.current.reset() + }, [state]) + + return ( +
    +

    Discussão

    + {comments.length > 0 ? ( +
      + {comments.map((comment) => ( +
    • +

      + {comment.authorName} + {comment.createdAt ? ( + <> + + + {dateTimeFormatter.format(new Date(comment.createdAt))} + + + ) : null} +

      +

      {comment.body}

      +
    • + ))} +
    + ) : null} + {canComment ? ( +
    + +