From 712d40f83fe25c9354fb878704004bf6638bd9fa Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 19:23:09 -0300 Subject: [PATCH] =?UTF-8?q?feat(B195):=20finalizar=20wizard=20Registrar=20?= =?UTF-8?q?pedido=20=E2=80=94=20campo=20=C3=BAnico=20+=20t=C3=ADtulo=20der?= =?UTF-8?q?ivado=20por=20IA=20+=20edi=C3=A7=C3=A3o=20no=20detalhe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG-AGENTS.md | 2 + docs/plans/escala-dry-pos-wizard-steps.md | 35 ++++ docs/plans/registrar-pedido-wizard-impl.md | 100 +++++++++++ .../campanha/(app)/acoes/[slug]/page.tsx | 29 ++- .../campanha/(app)/acoes/formActions.ts | 26 +++ .../(app)/demandas/[slug]/formActions.ts | 19 ++ .../campanha/(app)/demandas/[slug]/page.tsx | 12 +- .../(app)/demandas/nova/formActions.ts | 21 +-- src/app/(campaign)/campanha/actions/demand.ts | 80 ++++++++- src/collections/CampaignDemand.ts | 63 ++++++- .../demand/DemandDescriptionEditor.tsx | 112 ++++++++++++ src/components/campaign/demand/DemandForm.tsx | 58 +++--- .../shared/WizardMunicipalitySelectedStub.tsx | 30 ---- .../shared/WizardRegisterDemandStep.tsx | 166 ++++++++++++++++++ .../shared/useCampaignFormSuccessToast.ts | 9 +- src/lib/campaignActionRoutes.ts | 1 + src/lib/campaignWizardCopy.ts | 7 +- src/lib/demandTitle.ts | 24 +++ src/lib/schemas/activity.ts | 15 +- src/lib/schemas/campaignDemand.ts | 21 +++ src/lib/schemas/campaignDemandInput.ts | 23 ++- src/utilities/ai/campaignDemandTitle.ts | 44 +++++ .../demand/campaignDemandFormData.ts | 26 +++ tests/e2e/campaignRegisterDemand.e2e.spec.ts | 89 ++++++++++ tests/int/campaignDemandWorkflow.int.spec.ts | 135 ++++++++++++-- tests/unit/demandTitle.unit.spec.ts | 52 ++++++ 26 files changed, 1076 insertions(+), 123 deletions(-) create mode 100644 docs/plans/escala-dry-pos-wizard-steps.md create mode 100644 docs/plans/registrar-pedido-wizard-impl.md create mode 100644 src/app/(campaign)/campanha/(app)/acoes/formActions.ts create mode 100644 src/components/campaign/demand/DemandDescriptionEditor.tsx delete mode 100644 src/components/campaign/shared/WizardMunicipalitySelectedStub.tsx create mode 100644 src/components/campaign/shared/WizardRegisterDemandStep.tsx create mode 100644 src/lib/demandTitle.ts create mode 100644 src/utilities/ai/campaignDemandTitle.ts create mode 100644 src/utilities/demand/campaignDemandFormData.ts create mode 100644 tests/e2e/campaignRegisterDemand.e2e.spec.ts create mode 100644 tests/unit/demandTitle.unit.spec.ts diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 56c42ced..cf702b8c 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -6,6 +6,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-10):** **OPS33** (Issue #595) — `worktree next` **claima deterministicamente** antes de criar o worktree: mesma fila/ordem e lock otimista do `pnpm agent:claim`, com o claim extraído para `scripts/lib/agent-github.mjs` (`claimIssue` — lock + flip + comentário; `claimBriefLines` — brief compartilhado; `claimQueueEntry` — shape para o reopen; `claimTargetVerdict` — decisão pura testada) e o `agent-claim.mjs` virou CLI fino com a **mesma saída**; claim falhou (fila vazia/race/`--issue` inválido) → motivo e saída **sem worktree órfão** (branch validada antes do claim; hint de falha pós-claim). `--issue ` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress` — sem re-claim, idempotente; recupera também falha pós-claim); a saída imprime o brief e avisa "já claimada — NÃO rodar `pnpm agent:claim`". O **launch** do terminal agora envia `/work-issue --issue ` (prompt citado na diretiva) e a função `worktree()` de `.agents/shell/worktree.sh` passou a **tokenizar a diretiva via xargs** (quote-aware, nunca eval) — o valor do `--prompt` carrega espaço. `--stay` suprime cd+launch mas o claim acontece; o comando `/worktree` roda o mesmo script (claim incluso, sem launch). Guard `-B` contra reset de commits de sessão no reopen. Pool intacto (`claimIssueForPool` coordenado). Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue` (sai "read-only"), AGENT-OPS. Sem migration/dados/access/UI. Intenção [`worktree-next-claima-issue-deterministicamente.md`](plans/worktree-next-claima-issue-deterministicamente.md); impl [`worktree-next-claima-issue-deterministicamente-impl.md`](plans/worktree-next-claima-issue-deterministicamente-impl.md). +**Recently resolved (2026-08-10):** **B195** (Issue #591) — wizard "Registrar pedido" completo (A5, adiado no B85): o passo final saiu do stub e virou formulário funcional no shell do wizard — município no header do app (chrome `municipalityLabel`), **sem título grande de passo** (`stepTitle={null}` + `contentFocus="none"`), Tipo → Atividade (opcional) → **campo único** "O que você precisa?"; salvar ("Abrir demanda") → toast + retorno à origem (`returnPath`). **Mudança de modelo da demanda:** o cadastro (wizard e `/demandas/nova`, sem divergência) tem UM campo de texto (`description` required, min 2, max 4000 — constante `CAMPAIGN_DEMAND_BODY_MAX_LENGTH`); o **título virou derivado por IA** (`src/utilities/ai/campaignDemandTitle.ts` — `generateText` DeepSeek `deepseek-v4-flash` como o Sollinha, timeout 4s, sem key → `null`, prompt com rótulo do tipo) com **fallback = texto truncado** (`src/lib/demandTitle.ts`, puro: colapsa whitespace, ≤160) no create e **keep-previous na edição** (novo `updateCampaignDemandRecord` + editor inline no detalhe — recalc só quando a descrição muda; slug preservado pela invariante canônica); `campaignDemandCreateSchema` perdeu `title` (só os drafts de demanda da atividade mantêm título digitado — vertical fora de escopo, schema desacoplado). **Unicidade de slug defensiva só no create das forms** (`ensureUniqueDemandSlug`, opt-in `context.campaignDemandUniqueSlug` fail-closed em transação ativa — precedente `skipContactPhoneInvariant`): probe único da janela `-2…-20` + advisory lock `campaign-demand-slug:` contra race; activity drafts/admin mantêm a constraint estrita (contrato de rollback C90 pinado em teste). Page: branch `register-demand` no router + **staff gate ANTES do load do escopo** (`gate: 'staff'` → líder vai a `/campanha/contatos`, B43 — o 403 do scope load pré-existente virava erro não determinístico; stub `WizardMunicipalitySelectedStub` removido, placeholder "em breve" apagado). **Bug latente corrigido no hook compartilhado** `useCampaignFormSuccessToast` (dependência `[state.message, state.status]` → `[state]`: segundo save com a mesma mensagem — caso do editor — não disparava toast/refresh). Copy compartilhada em `schemas/campaignDemand.ts`; parser único `parseCampaignDemandCreateFormData` (`utilities/demand/`). **Sem migration** (título continua required, preenchido pelo record). Testes: unit do fallback/limites exatos; int (create deriva, colisão `-2`/`-3`, min 2, update keep-previous/skip, leader edit bloqueado, pin "sem `DEEPSEEK_API_KEY`" no `beforeAll`); e2e novo `campaignRegisterDemand` (fluxo completo com título na lista, back-nav à busca, lockdown de líder). Intenção [`registrar-pedido-wizard.md`](plans/registrar-pedido-wizard.md); impl [`registrar-pedido-wizard-impl.md`](plans/registrar-pedido-wizard-impl.md). + **Recently resolved (2026-08-10):** **OPS28** (Issue #584) — `worktree next`/`plan` passam a aplicar o **seed mínimo** além das migrations em cada banco provisionado (`migrate` + `db:seed:minimal` por banco, dev e test) — paridade com a CI, que roda o mesmo par antes do e2e: o e2e de worktree deixa de divergir do de CI (specs dependentes dos pins `priority: 'alta'` de `salvador-ze-1`/`camacari` — FAB suggest e `campaignSavedFilters` — falhavam só localmente com banco migrado cru 435×normal, o padrão que o ledger TECH-DEBT row 19 ficha como "masks real failures"). `--no-migrate` pula migrate **e** seed (o seed depende do catálogo migrado); re-provisionamento continua idempotente (upserts). Fallback sem Docker (Cursor Cloud) também roda migrate+seed nos `teqo`/`teqo_test` compartilhados (decisão de produto do gate: paridade total). **Guard de regressão** `tests/e2e/seedParity.setup.e2e.spec.ts` no projeto `setup` do Playwright: falha cedo com mensagem clara ("rode `pnpm migrate && pnpm db:seed:minimal`") se o banco de teste não estiver seedado — dependentes do setup pulam, nada de spec vermelho confuso; a evidência checada são os **users do seed** (`MINIMAL_CAMPAIGN_USERS`), deliberadamente não os pins `priority: 'alta'` — o cleanup dos fixtures e2e reseta `priority` dos municípios que toca (round-robin por sequência persistente), então pins morrem legitimamente após runs repetidas no mesmo banco de worktree e só o `pnpm worktree next` re-aplica (guard nunca checaria pins); `scripts/worktree.mjs` entrou em `HIGH_RISK_EXACT` (mudança no provisionador roda a suíte cheia na CI). Sem migration/access/Consent/UI. Intenção [`worktree-seed-minimal-paridade-e2e-ci.md`](plans/worktree-seed-minimal-paridade-e2e-ci.md); impl [`worktree-seed-minimal-paridade-e2e-ci-impl.md`](plans/worktree-seed-minimal-paridade-e2e-ci-impl.md). **Recently resolved (2026-08-10):** **OPS34** (Issue #599) — o job `e2e` do CI (o mais longo da cascata, 435 s medidos no run 31412425553: ~67 s fixos + build 172 s + testes 196 s) roda agora **em 2 shards paralelos** (`--shard=N/2`, matrix `fail-fast: false`, build por shard — opção A da intenção validada pela medição: por shard ≈ 5–5,5 min; artefato de build compartilhado rejeitado porque os shards só começam após o build → wall maior). **Cadeia de projetos do Playwright (`setup → campaign → frontend → admin`) + prewarm do `setup` viraram dev-only:** `playwright.config.ts` ganhou `isProdMode = CI || E2E_PROD=1` (mesma condição do `webServer.command`, agora reusada) — em prod o projeto `setup` sai do array (arquivo não casado = não coletado) e as `dependencies` zeram (famílias em paralelo contra o build de produção, onde não há compile a preaquecer); dev intocado (provado: `--list` mostra `[setup]` só sem CI; smoke e2e dev rodou setup→campaignAuth). `ci-pr.yml`: o job `scope` emite `e2e_matrix`/`e2e_shards` via novo `e2eShardConfig(mode)` em `scripts/lib/test-affected-core.mjs` (fonte única, unit-pinada: full → `[1,2]`/2, selected/none → `[1]`/1 — pagar 2 builds por 3 specs é desperdício); `ci.yml` hardcodeia `[1,2]`/2 com comentário apontando o single-source. `PLAYWRIGHT_WORKERS: 2` explícito no env dos jobs e2e (aceite: explícito, default local intocado). Armadilha registrada nos workflows: flags do playwright vão **sem** o separador `--` — pnpm repassa o `--` literal ao playwright e a flag vira file pattern ("No tests found"). Specs intactas (o `setup` não foi removido — virou dev-only, já documentado como prewarm dev-only); sem migration/access/Consent/UI. **Medição pós-merge (main, run 31418206512):** job e2e 435 s → **326 s (shard 1) / 369 s (shard 2)** — shard 2 = fixo 68 s + build 176 s + testes 124 s (a fase de testes caiu de 196 s seriais para 124 s — metade da suite por runner + cadeia removida; o shard 2 ainda pagou retry da flake conhecida de `campaignMunicipalities`; shard 1: testes ~82 s); gate completo do pipeline (checks) ~7,5 → ~6,4 min. Build passou a dominar o shard — gatilho da intenção (opção B, artefato compartilhado) reavaliável quando os testes encolherem mais. Intenção [`ci-e2e-paralelizar-job.md`](plans/ci-e2e-paralelizar-job.md); impl [`ci-e2e-paralelizar-job-impl.md`](plans/ci-e2e-paralelizar-job-impl.md). diff --git a/docs/plans/escala-dry-pos-wizard-steps.md b/docs/plans/escala-dry-pos-wizard-steps.md new file mode 100644 index 00000000..925262fb --- /dev/null +++ b/docs/plans/escala-dry-pos-wizard-steps.md @@ -0,0 +1,35 @@ +# Escala/DRY: chrome comum dos passos finais do wizard (pós-B195) + +Status: rascunho +Atualizado em: 2026-08-10 +Issue: #597 (D11) +Priority: P2 +Kind: chore +Depends: #591 (B195) +Appetite: ~0,5–1 dia eng + +## O débito (colhido no /simplify do B195) + +O passo final dos wizards de `/campanha/acoes` duplica o mesmo chrome: + +1. **Submit bar + pendência** — `aria-busy`/`data-pending` no form, bloco de botão com `Spinner` + "Salvando…", `aria-live` sr-only de pendência: idênticos em `WizardRegisterDemandStep`, `WizardUpdateBodyStep`, `WizardTrendNoteStep` e `WizardExpectedVotesStep`. +2. **Campos do cadastro de demanda** — Tipo (NativeSelect), Atividade (`AsyncSearchCombobox`) e campo único de texto: copy-paste entre `WizardRegisterDemandStep` e `DemandForm` (diferem só no prefixo de `id` e no seletor de município, necessário só no `/demandas/nova`). + +Extração proposta: `WizardStepFormChrome` (form + pending chrome + submit bar como props `pendingAnnouncement`/`ctaLabel`) e um componente de campos de demanda parametrizado por prefixo de id + `showMunicipality`. Copy já vive em `schemas/campaignDemand.ts` (B195) — o lote não mexe em copy. + +## Appetite e rabbit holes + +- Tocar os 3 steps legados exige rodar o e2e de `campaignHomeActions` + `campaignWizardChrome` (regressão de foco/a11y: `contentFocus`, aria-live, label do botão). +- Rabbit hole: "extrair o shell inteiro" (cada step tem corpo próprio — NÃO unificar os corpos); não renomear arquivos de steps (histórico de PRs); não mexer em copy nem em comportamento (toast/push/retorno intactos). + +## Já resolvido no simplify do B195 (não reabrir) + +- Constantes de copy compartilhadas (`CAMPAIGN_DEMAND_*` em `lib/schemas/campaignDemand.ts`), parser único `parseCampaignDemandCreateFormData`, limite central `CAMPAIGN_DEMAND_BODY_MAX_LENGTH`, `htmlFor` órfãos removidos, CTA único `CAMPAIGN_DEMAND_SUBMIT_LABEL`, bug do `useCampaignFormSuccessToast` corrigido no hook. + +## Explicitamente fora (defers do triage do B195) + +- Fail-closed de `campaignDemandUniqueSlug` sem teste (gatilho: próxima mudança no hook). +- Copy "Informe um título com letras ou números." num form sem campo de título (decisão de produto; gatilho: feedback em campo). +- Casa de copy inconsistente (`WIZARD_UPDATE_*` em `wizardUpdateUi.ts` vs `WIZARD_DEMAND_*` em `campaignWizardCopy.ts`) — cosmético. +- `canLeaderEdit` morto em `campaignDemandData.ts` (pré-existente). +- Update de demanda com descrição inalterada ainda grava (documentado em teste). diff --git a/docs/plans/registrar-pedido-wizard-impl.md b/docs/plans/registrar-pedido-wizard-impl.md new file mode 100644 index 00000000..dba153d8 --- /dev/null +++ b/docs/plans/registrar-pedido-wizard-impl.md @@ -0,0 +1,100 @@ +# Impl: Finalizar o wizard "Registrar pedido" (A5) + +Status: aprovado +Atualizado em: 2026-08-10 +Issue: #591 +Intenção: docs/plans/registrar-pedido-wizard.md +Appetite restante: herdado (~1,5–2 dias eng) — sem corte necessário + +## Leitura da intenção + +- **Outcome:** "Registrar pedido" (Início e FAB do município) termina em formulário funcional no wizard — nunca no placeholder "em breve". Passo final: município no header do app (chrome `municipalityLabel`), **sem** título grande de passo, **sem** seletor de município; Tipo → Atividade (opcional) → **um único campo de texto** ("O que você precisa?"). A demanda nasce com **título derivado por IA** (DeepSeek server-side, mesmo caminho do Sollinha) com **fallback = texto truncado** no create; ao salvar, confirmação (toast) + retorno à origem (`returnPath`). Campo único vale também em `/demandas/nova` (que mantém o seletor de município). Launchers de lista/detalhe permanecem no form (decisão de gate Q3-B). +- **O que NÃO negociar:** leader lockdown intacto (staff-only na criação e no wizard); criar a demanda **nunca falha nem espera indefinidamente por causa da IA**; slug **só nasce no create** (URLs/listas existentes intocadas); copy pt-BR / identificadores em inglês; invariantes do engineering-brief (Local API `overrideAccess: false`; escrita multi-collection em transação — aqui a escrita é single-collection). +- **O que reavaliar:** + - _Onde derivar o título_ — a intenção deixa a forma com o executor; decido: no shared record action, **fora** da transação (não segurar lock durante a IA). + - _"A descrição (campo único) é editável"_ — hoje **não existe** superfície de edição de demanda no app de campanha (nem descrição, nem título): o detalhe B85 é read-only (só workflow/custo/comprovante). O aceite pede edição com recálculo de título por IA + slug preservado → decisão abaixo. + - _Colisão de slugs_ — com títulos derivados por IA, títulos idênticos em municípios diferentes ficam prováveis; o `unique: true` atual viraria erro de criação (viola "nunca falhar"). Preciso de unicidade defensiva no create. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[Launcher Início / FAB município] --> B["/acoes/registrar-pedido (municipio + from)"] + B --> C{WizardMunicipalitySearchStep} + C -->|sem municipio| D[busca existente] + D --> C + C -->|com municipio, staff| E[WizardRegisterDemandStep] + E --> F[createWizardDemandFormAction] + F --> G[createCampaignDemandRecord] + G --> H[deriveDemandTitle: IA timeout 4s] + H -->|falha / sem key| I[fallbackDemandTitle: truncado <=160] + H -->|ok| J[título IA validado] + I --> K + J --> K[payload.create: título + slug único + workflow] + K --> L[(campaignDemand)] + L --> M[toast + recordLastActed + returnPath] + M --> N[origem: Início / município] + O["/demandas/nova DemandForm (campo único)"] --> G + P[Detalhe: editar descrição - staff] --> Q[updateCampaignDemandRecord] + Q -->|descrição mudou| R[deriveDemandTitle] + R -->|falha| S[mantém título anterior] + R -->|ok| T[título recalculado] + S --> U[payload.update: slug preservado pelo hook] + T --> U +``` + +**Opções consideradas:** + +1. **Onde deriva o título** — A) dentro de `createCampaignDemandRecord` (um dono, ambas as formas ganham automaticamente, IA fora da transação) | B) nas duas form actions (2 call sites duplicados) | C) hook `beforeChange` da collection (cobre admin, mas AI em hook = latência/estado invisível no save do admin + risco de falha no meio de um fluxo sem UI de erro — rejeitada). +2. **Fallback no create** — truncamento puro do campo único a ≤160 chars (respeita `title.maxLength`), com guarda de slugificabilidade. Política do fallback **no caller** (create → truncar; update → manter anterior), módulo de IA devolve `string | null`. +3. **Colisão de slug** — A) unicidade defensiva no create: hook `beforeValidate` consulta o slug candidato (`payload.find`, `limit: 1`) e sufixa `-2`, `-3`… (capa ~20); cobre **todos** os caminhos de create (ações + admin) | B) manter erro de duplicata atual — com IA vira falha provável de criação | C) uniquify só na action — admin continua quebrando. Recomendação: **A**. +4. **"Descrição editável"** — A) affordance mínima de edição no detalhe (staff): botão "Editar" na seção de descrição + `updateCampaignDemandRecord` (recalc de título por IA quando a descrição muda; falha → mantém título anterior; slug preservado — invariante já existe no hook `setCanonicalDemandSlug`) | B) sem UI de edição no app (edição só no admin Payload, título digitado manualmente) — deixa o aceite sem caminho verificável de ponta a ponta. Recomendação: **A** — é o único jeito de o aceite "descrição é editável + título recalculado na edição" ser real; superfície mínima, sem redesenhar a vertical. +5. **Passo do wizard** — componente novo no padrão `WizardUpdateBodyStep` (`useActionState` + `runCampaignFormAction` + `useCampaignFormSuccessToast` → `recordLastActedMunicipality` + `router.push(wizardReturnHref(returnPath))`); `stepTitle={null}` + `contentFocus="none"` (form começa direto, município no header). +6. **Stub** — `WizardMunicipalitySelectedStub` fica morto depois do branch novo (os 5 slugs ficam todos roteados) → remover, junto de `wizardNextStepPlaceholder`/`WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER` (knip não tolera mortos). + +**Rejeitadas:** hook de IA na collection (opção 1C); título assíncrono (gate já descartou); campo título mantido como input do usuário (rabbit hole do plano); edição no detalhe via redirecionamento para form novo (custo/UI maiores, sem ganho). + +### Componentes / mudanças + +- **`src/utilities/ai/campaignDemandTitle.ts`** (novo, server-only): `deriveDemandTitle(description, kind?) → Promise` — `generateText` (`@ai-sdk/deepseek`, `deepSeek('deepseek-v4-flash')` — mesmo modelo do chat Sollinha), prompt pt-BR curto (título ≤80 chars, sem pontuação final, com o rótulo do tipo), `maxOutputTokens` ~100, `AbortSignal.timeout(4000)`; sem `DEEPSEEK_API_KEY` → `null` imediato; qualquer erro → `null`; valida 1..160 + slugificável. `fallbackDemandTitle(description) → string` — puro, truncado ≤160, exportado para unit test. +- **`src/lib/schemas/campaignDemandInput.ts`**: `title` → `optional` no `campaignDemandCreateSchema` (derivado pelo record); novo `campaignDemandUpdateSchema` (`{ id, description }`). +- **`src/app/(campaign)/campanha/actions/demand.ts`**: `createCampaignDemandRecord` resolve o título antes de abrir a transação (`derive ?? fallback`, com guarda de slugificabilidade → cai no fallback); novo `updateCampaignDemandRecord`/`updateCampaignDemand` (recalc só se `description` mudou; transação + `acquireTextAdvisoryLocks('campaign-demand:')` — mesmo padrão dos outros updates do arquivo). +- **`src/collections/CampaignDemand.ts`**: novo `beforeValidate` `ensureUniqueDemandSlug` (create-only; consulta `payload.find` por slug candidato e sufixa `-2`… até cap; preserva `unique: true`). `title` permanece required (sempre preenchido pelo record) — **sem migration** (nenhum campo novo; dados existentes já têm títulos descritivos válidos). +- **`src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts`**: para de ler `title` do FormData (schema opcional; record deriva). Redirect para `/demandas/` continua (decisão de gate). +- **`src/components/campaign/demand/DemandForm.tsx`**: remove o input de título; campo único "O que você precisa?" (Textarea, required, min 2, max 4000) **após** Tipo e Atividade (Tipo | Município grid → Atividade → campo único); mantém seletor de município (necessário lá). +- **`src/app/(campaign)/campanha/(app)/acoes/formActions.ts`** (novo): `createWizardDemandFormAction` — `runCampaignFormAction` (sem redirect), parseia kind/municipalityId/activityId/description, chama `createCampaignDemand`, retorna `{ status: 'success', message: WIZARD_DEMAND_SAVED_MESSAGE }`. +- **`src/components/campaign/shared/WizardRegisterDemandStep.tsx`** (novo, client): `CampaignWizardShell` com `flowTitle`, `stepTitle={null}`, `contentFocus="none"`, `municipalityLabel`, `previousHref` via `wizardPreviousHref({ stepKind: 'register-demand' })` (adicionar `'register-demand'` a `WizardStepKind` em `campaignActionRoutes.ts` — back = busca do próprio fluxo, padrão dos demais), `dismissHref`; hidden `municipalityId`/`municipalitySlug`; Tipo (NativeSelect), Atividade (`AsyncSearchCombobox` + `searchDemandActivityOptions` reusado), campo único; botão "Abrir demanda" com spinner; aria-live de salvando; `useCampaignFormSuccessToast` → `recordLastActedMunicipality` + retorno. +- **`src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx`**: branch `register-demand` **antes** do fallback — staff-gate (`isStaffCampaignRole`, senão `notFound()` — leader lockdown defensivo; o access já negaria, mas não renderizar form para leader) → ``. Remove o stub. +- **`src/lib/campaignWizardCopy.ts`**: remove `wizardNextStepPlaceholder` + placeholder genérico; adiciona copy do passo (`WIZARD_DEMAND_SAVED_MESSAGE`, `WIZARD_DEMAND_FINAL_CTA_LABEL`, labels do campo único/atividade, mensagens aria). +- **Edição (decisão 4A):** `src/components/campaign/demand/DemandDescriptionEditor.tsx` (client, staff-only; textarea + Salvar/Cancelar; `useActionState(updateDemandFormAction)` + toast/refresh) + seção "Editar" no detalhe (`demandas/[slug]/page.tsx`) + `updateDemandFormAction` em `demandas/[slug]/formActions.ts` (revalidatePath do detalhe). +- **Migration:** nenhuma. + +### Dados → forma (se aplicável) + +N/A — formulário de entrada, sem dados de apresentação (nenhum KPI/mapa/série). + +## Fases verificáveis + +1. **Tracer / schema+server** — módulo de IA + fallback puro, schemas, actions (create derive + update), hook de unicidade, copy; unit tests do fallback; int tests (create com description → título truncado sem chave AI + slug único; colisão → `-2`; update → título anterior mantido sem chave AI + slug preservado; staff-gate). _Quota do appetite: ~40%._ +2. **UI** — `DemandForm` campo único; `WizardRegisterDemandStep`; branch na página; remoção do stub; editor de descrição no detalhe. Impeccable B: shape → craft → critique → polish. _~35%._ +3. **Gates** — e2e do fluxo do wizard (busca → passo → salvar → retorno à origem; e sem título grande de passo) + `pnpm gate:fast`; `pnpm push` → PR. _~25%._ + +## Rabbit holes / Não escopo (engenharia) + +- Não criar "segundo campo invisível" de título na UI; não tocar launchers de lista/detalhe (`demandCreateHref`); não redesenhar lista/detalhe além da seção de edição; não mexer nos outros 4 wizards; não adicionar rate-limit próprio para o título (1 chamada por save; o chat tem o dele); não revalidar cache (`/campanha` é dinâmico); não adicionar `title` como campo admin readOnly (admin pode corrigir título ruim manualmente — slug preservado pelo hook). +- Barato adiado: prompt do título com contexto de município (exigiria lookup extra no record) — gatilho: títulos genéricos demais relatados em campo; e2e mobile do passo (coberto pelo e2e desktop + chrome existente). + +## Riscos e mitigação + +- **IA lenta/instável no save** → timeout 4s + fallback truncado; spinner no botão; nunca bloqueia a criação. Sem chave (dev/test) → derive devolve `null` imediato, int tests exercitam o caminho do fallback. +- **Colisão de slug por títulos de IA** → unicidade defensiva no create; cap de tentativas → erro claro. +- **Admin com título manual + descrição vazia** → permitido (descrição não é required na collection; wizard/form exigem o campo único). +- **Quebrar B85** → `/demandas/nova` segue com redirect ao detalhe; slug/URLs existentes intocados (hook preserva slug no update — invariante canônico). +- **Leader reachando o passo por deep-link** → staff-gate no branch + access já nega criação (defesa em camadas). +- **Mudança de comportamento do hook de slug (admin)** → duplicata deixa de dar erro e vira `-2`; aceitável e testado. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (wizard funcional, campo único nos dois cadastros, título IA + fallback, retorno à origem, launchers intactos, leader lockdown) +- [x] Invariantes AGENTS/engineering-standards (overrideAccess false, copy pt-BR, identificadores EN, sem migration, gates no fechamento) +- [x] Testes de domínio previstos (unit do fallback; int de create/update/unicidade/access; e2e do fluxo do wizard) diff --git a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx index 822898d4..247dbaf3 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx @@ -6,7 +6,7 @@ import { WizardTrendNoteStep } from '@/components/campaign/municipality/WizardTr import { WizardUpdateBodyStep } from '@/components/campaign/municipality/WizardUpdateBodyStep' import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep' import { WizardMunicipalitySearchStep } from '@/components/campaign/shared/WizardMunicipalitySearchStep' -import { WizardMunicipalitySelectedStub } from '@/components/campaign/shared/WizardMunicipalitySelectedStub' +import { WizardRegisterDemandStep } from '@/components/campaign/shared/WizardRegisterDemandStep' import { CAMPAIGN_WIZARD_ACTION_SLUGS, hasWizardScenarioParam, @@ -78,7 +78,12 @@ export default async function CampaignActionWizardPage({ } const needsPoliticalTrend = slug === CAMPAIGN_WIZARD_ACTION_SLUGS['change-trend'] - const user = await requireCampaignPageActor() + // Demands are staff-only (A5): gate before the municipality scope loads, + // so a leader gets the standard contacts-home redirect (B43) instead of a + // 403/404 — deterministic in dev and production alike. + const user = await requireCampaignPageActor( + slug === CAMPAIGN_WIZARD_ACTION_SLUGS['register-demand'] ? { gate: 'staff' } : undefined, + ) const payload = await getPayload({ config: await config }) const { municipalities } = await loadMunicipalityScope( payload, @@ -186,11 +191,17 @@ export default async function CampaignActionWizardPage({ ) } - return ( - - ) + if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['register-demand']) { + return ( + + ) + } + + notFound() } diff --git a/src/app/(campaign)/campanha/(app)/acoes/formActions.ts b/src/app/(campaign)/campanha/(app)/acoes/formActions.ts new file mode 100644 index 00000000..a0993d39 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/acoes/formActions.ts @@ -0,0 +1,26 @@ +'use server' + +import { createCampaignDemand } from '@/app/(campaign)/campanha/actions/demand' +import { WIZARD_DEMAND_SAVED_MESSAGE } from '@/lib/campaignWizardCopy' +import { + runCampaignFormAction, + type CampaignFormActionState, +} from '@/utilities/campaignFormActionError' +import { parseCampaignDemandCreateFormData } from '@/utilities/demand/campaignDemandFormData' + +/** + * Stay-on-page form action for the register-demand wizard step (B195): same + * create path as `/demandas/nova`, but the wizard owns the success toast and + * the return navigation (`returnPath`), so there is no redirect here. + */ +export const createWizardDemandFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + await createCampaignDemand(parseCampaignDemandCreateFormData(formData)) + return { message: WIZARD_DEMAND_SAVED_MESSAGE } + }, + genericMessage: 'Não foi possível abrir a demanda. Verifique os dados e tente novamente.', + }) diff --git a/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts b/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts index 8bf9d62e..cdbcf193 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts @@ -6,6 +6,7 @@ import { attachCampaignDemandReceiptRecord, setCampaignDemandCost, transitionCampaignDemand, + updateCampaignDemand, } from '@/app/(campaign)/campanha/actions/demand' import { FormDataBoundaryError, @@ -15,6 +16,7 @@ import { } from '@/lib/formData' import { CAMPAIGN_DEMAND_COST_STAFF_MESSAGE, + CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE, CAMPAIGN_DEMAND_INVALID_STATUS_MESSAGE, CAMPAIGN_DEMAND_RECEIPT_SAFE_MESSAGES, CAMPAIGN_DEMAND_TRANSITION_SAFE_MESSAGES, @@ -54,6 +56,23 @@ export const transitionDemandFormAction = async ( genericMessage: 'Não foi possível mover a demanda. Verifique seu acesso e tente novamente.', }) +export const updateDemandFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + await updateCampaignDemand({ + id: requiredRelationshipFormValue(formData, 'demandId'), + description: requiredFormText(formData, 'description'), + }) + revalidatePath('/campanha/demandas/[slug]', 'page') + return { message: 'Descrição atualizada.' } + }, + safeMessages: [CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE], + genericMessage: 'Não foi possível editar a demanda. Tente novamente.', + }) + export const setDemandCostFormAction = async ( _state: CampaignFormActionState, formData: FormData, diff --git a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx index 6bd8caef..15a9619e 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { notFound } from 'next/navigation' import { getPayload } from 'payload' +import { DemandDescriptionEditor } from '@/components/campaign/demand/DemandDescriptionEditor' import { DemandWorkflowCard } from '@/components/campaign/demand/DemandWorkflowCard' import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' @@ -22,6 +23,7 @@ import { attachDemandReceiptFormAction, setDemandCostFormAction, transitionDemandFormAction, + updateDemandFormAction, } from './formActions' export async function generateMetadata({ params }: DemandDetailPageProps) { @@ -116,11 +118,11 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps {dateTimeFormatter.format(new Date(demand.createdAt))}

- {demand.description ? ( -
-

{demand.description}

-
- ) : null} + {demand.decisionNote && (demand.status === 'aprovada' || demand.status === 'rejeitada') ? (
diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts index 4f93e504..b5d8019e 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts @@ -1,35 +1,18 @@ 'use server' import { createCampaignDemand } from '@/app/(campaign)/campanha/actions/demand' -import { - nullableRelationshipFormValue, - optionalFormText, - requiredFormText, - requiredRelationshipFormValue, -} from '@/lib/formData' -import { campaignDemandKinds, type CampaignDemandKind } from '@/lib/schemas/campaignDemand' import { runCampaignRedirectFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' +import { parseCampaignDemandCreateFormData } from '@/utilities/demand/campaignDemandFormData' export const createDemandFormAction = async ( _state: CampaignFormActionState, formData: FormData, ): Promise => runCampaignRedirectFormAction({ - execute: () => { - const rawKind = requiredFormText(formData, 'kind') - return createCampaignDemand({ - title: requiredFormText(formData, 'title'), - kind: campaignDemandKinds.includes(rawKind as CampaignDemandKind) - ? (rawKind as CampaignDemandKind) - : 'outro', - description: optionalFormText(formData, 'description'), - municipality: requiredRelationshipFormValue(formData, 'municipalityId'), - activity: nullableRelationshipFormValue(formData, 'activityId') ?? undefined, - }) - }, + execute: () => createCampaignDemand(parseCampaignDemandCreateFormData(formData)), redirectTo: (demand) => `/campanha/demandas/${demand.slug}`, genericMessage: 'Não foi possível abrir a demanda. Verifique os dados e tente novamente.', }) diff --git a/src/app/(campaign)/campanha/actions/demand.ts b/src/app/(campaign)/campanha/actions/demand.ts index e5746786..5c011378 100644 --- a/src/app/(campaign)/campanha/actions/demand.ts +++ b/src/app/(campaign)/campanha/actions/demand.ts @@ -2,8 +2,11 @@ import type { Payload } from 'payload' +import { fallbackDemandTitle } from '@/lib/demandTitle' +import type { CampaignDemandKind } from '@/lib/schemas/campaignDemand' import { CAMPAIGN_DEMAND_COST_STAFF_MESSAGE, + CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE, CAMPAIGN_DEMAND_RECEIPT_EMPTY_MESSAGE, CAMPAIGN_DEMAND_RECEIPT_SIZE_MESSAGE, CAMPAIGN_DEMAND_RECEIPT_STAFF_MESSAGE, @@ -14,11 +17,14 @@ import { campaignDemandCostSchema, campaignDemandCreateSchema, campaignDemandTransitionSchema, + campaignDemandUpdateSchema, type CampaignDemandCostInput, type CampaignDemandCreateInput, type CampaignDemandTransitionInput, + type CampaignDemandUpdateInput, } from '@/lib/schemas/campaignDemandInput' import type { CampaignUser } from '@/payload-types' +import { deriveDemandTitle } from '@/utilities/ai/campaignDemandTitle' import { isCampaignStaff } from '@/utilities/campaignAccess' import { getCampaignActionContext, @@ -35,24 +41,85 @@ export const createCampaignDemandRecord = async ( input: CampaignDemandCreateInput, ) => { const data = campaignDemandCreateSchema.parse(input) + // AI call BEFORE the transaction opens: a slow model never holds the + // transaction or its advisory locks, and creating never fails on the AI + // (missing key / timeout / unusable output → truncated-title fallback). + const title = + (await deriveDemandTitle(data.description, data.kind)) ?? fallbackDemandTitle(data.description) return withPayloadTransaction( payload, async ({ req }) => { const currentActor = await reloadCampaignActor(payload, actor, req) - // Access create rule validates municipality scope per role; the collection hook - // links a leader's own leadership and enforces the initial status. + // Opt in to slug uniquification for form-created demands: the AI-derived + // title may collide across municipalities and a duplicate must not fail + // the create (the hook honors the flag only inside this transaction). return payload.create({ collection: 'campaignDemand', - data: hookFilledCreateData<'campaignDemand'>(data), + data: hookFilledCreateData<'campaignDemand'>({ ...data, title }), + depth: 0, + user: currentActor, + overrideAccess: false, + req, + context: { campaignDemandUniqueSlug: true }, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar o registro da demanda.' }, + ) +} + +export const updateCampaignDemandRecord = async ( + payload: Payload, + actor: CampaignUser, + input: CampaignDemandUpdateInput, +) => { + const { id, description } = campaignDemandUpdateSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + if (!isCampaignStaff(currentActor)) { + throw new Error(CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE) + } + + // Access-scoped read (staff see only their municipalities). The AI call + // stays before the advisory lock, so the lock is never held open by it. + const demand = await payload.findByID({ + collection: 'campaignDemand', + id, + depth: 0, + select: { title: true, kind: true, description: true }, + user: currentActor, + overrideAccess: false, + req, + }) + + // Re-derive the title only when the free text actually changed; on AI + // failure the previous title is kept (fallback by truncation is a + // create-only policy). The slug is preserved by the collection hook. + const derived = + (demand.description ?? '') === description + ? null + : await deriveDemandTitle(description, demand.kind as CampaignDemandKind) + + await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${id}`]) + + return payload.update({ + collection: 'campaignDemand', + id, + data: { + description, + ...(derived ? { title: derived } : {}), + }, depth: 0, user: currentActor, overrideAccess: false, req, }) }, - { beginFailureMessage: 'Não foi possível iniciar o registro da demanda.' }, + { beginFailureMessage: 'Não foi possível iniciar a edição da demanda.' }, ) } @@ -194,6 +261,11 @@ export const createCampaignDemand = async (input: CampaignDemandCreateInput) => return createCampaignDemandRecord(payload, actor, input) } +export const updateCampaignDemand = async (input: CampaignDemandUpdateInput) => { + const { payload, actor } = await getCampaignActionContext() + return updateCampaignDemandRecord(payload, actor, input) +} + export const transitionCampaignDemand = async (input: CampaignDemandTransitionInput) => { const { payload, actor } = await getCampaignActionContext() return transitionCampaignDemandRecord(payload, actor, input) diff --git a/src/collections/CampaignDemand.ts b/src/collections/CampaignDemand.ts index 44883888..b4a5e914 100644 --- a/src/collections/CampaignDemand.ts +++ b/src/collections/CampaignDemand.ts @@ -29,6 +29,7 @@ import { isCampaignUnrestricted, } from '@/utilities/campaignAccess' import { systemStampedActorField } from '@/utilities/campaignAuditFields' +import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' const DEMAND_KIND_OPTIONS = campaignDemandKinds.map((value) => ({ value, @@ -52,6 +53,62 @@ const setCanonicalDemandSlug: CollectionBeforeValidateHook = ({ data, operation, return data } +const MAX_DEMAND_SLUG_ATTEMPTS = 20 + +/** + * With AI-derived titles (B195), equal titles across municipalities become + * likely, and the `unique` slug constraint must not turn that into a failed + * create — a demand creation can never fail over its title. Suffixes the + * candidate slug (`-2`, `-3`, …) against existing rows; runs after + * `setCanonicalDemandSlug` so `data.slug` is already the canonical candidate. + * + * Opt-in via `req.context.campaignDemandUniqueSlug` (set by the form-created + * record action): direct creates — the activity demand drafts (C90) and the + * admin UI — keep the strict constraint, which the activity rollback contract + * pins. Fail closed like `skipContactPhoneInvariant`: only honored inside an + * active transaction. A text advisory lock on the base slug serializes + * concurrent creates of the same derived title, so the probe cannot race the + * insert. + */ +const ensureUniqueDemandSlug: CollectionBeforeValidateHook = async ({ data, operation, req }) => { + if (!data || operation !== 'create' || !data.slug) return data + + if (req.context?.campaignDemandUniqueSlug === true) { + if (req.transactionID == null) { + throw new APIError('campaignDemandUniqueSlug exige uma transação ativa.', 500) + } + } else { + return data + } + + const baseSlug = data.slug + await acquireTextAdvisoryLocks(req.payload, req, [`campaign-demand-slug:${baseSlug}`]) + + // One probe for the whole candidate window instead of one query per attempt. + const candidates = Array.from({ length: MAX_DEMAND_SLUG_ATTEMPTS }, (_, index) => + index === 0 ? baseSlug : `${baseSlug}-${index + 1}`, + ) + // Intentional admin bypass: the slug-existence probe must see every demand + // row, including ones the creating staff cannot yet read. + const existing = await req.payload.find({ + collection: 'campaignDemand', + where: { slug: { in: candidates } }, + depth: 0, + limit: 0, + pagination: false, + overrideAccess: true, + req, + }) + const existingSlugs = new Set(existing.docs.map((doc) => doc.slug)) + for (const candidate of candidates) { + if (!existingSlugs.has(candidate)) { + data.slug = candidate + return data + } + } + throw new APIError('Não foi possível gerar um endereço único para a demanda.', 409) +} + const isDemandStatus = (value: unknown): value is CampaignDemandStatus => typeof value === 'string' && (campaignDemandStatuses as readonly string[]).includes(value) @@ -197,7 +254,11 @@ export const CampaignDemand: CollectionConfig = { delete: canDeleteCampaignDemand, }, hooks: { - beforeValidate: [setCanonicalDemandSlug, validateDemandActivityMunicipality], + beforeValidate: [ + setCanonicalDemandSlug, + ensureUniqueDemandSlug, + validateDemandActivityMunicipality, + ], beforeChange: [enforceDemandWorkflow], }, fields: [ diff --git a/src/components/campaign/demand/DemandDescriptionEditor.tsx b/src/components/campaign/demand/DemandDescriptionEditor.tsx new file mode 100644 index 00000000..4c0e9d1c --- /dev/null +++ b/src/components/campaign/demand/DemandDescriptionEditor.tsx @@ -0,0 +1,112 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useActionState, useState } from 'react' + +import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' +import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast' +import { Button } from '@/components/ui/button' +import { Field, FieldError, FieldLabel } from '@/components/ui/field' +import { Spinner } from '@/components/ui/Spinner' +import { Textarea } from '@/components/ui/textarea' +import { + CAMPAIGN_DEMAND_BODY_LABEL, + CAMPAIGN_DEMAND_BODY_MAX_LENGTH, +} from '@/lib/schemas/campaignDemand' +import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' + +type DemandDescriptionEditorProps = { + demandId: number + initialDescription: string + formAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise +} + +/** + * Inline edit of the single demand free-text field (B195): saving re-derives + * the AI title server-side (previous title kept on AI failure) and the + * canonical slug never changes — the page refreshes to the new title. + */ +export const DemandDescriptionEditor = ({ + demandId, + initialDescription, + formAction, +}: DemandDescriptionEditorProps) => { + const router = useRouter() + const [editing, setEditing] = useState(false) + const [state, submitAction, isPending] = useActionState(formAction, {}) + + useCampaignFormSuccessToast(state, () => { + setEditing(false) + router.refresh() + }) + + if (!editing) { + return ( +
+
+

{initialDescription}

+ +
+
+ ) + } + + return ( +
+
+ + + {CAMPAIGN_DEMAND_BODY_LABEL} +