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 (
+
+
+
+ )
+}
diff --git a/src/components/campaign/demand/DemandForm.tsx b/src/components/campaign/demand/DemandForm.tsx
index d98d22de..b4bdba98 100644
--- a/src/components/campaign/demand/DemandForm.tsx
+++ b/src/components/campaign/demand/DemandForm.tsx
@@ -7,11 +7,23 @@ import { CampaignFormActionMessage } from '@/components/campaign/shared/Campaign
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { Button } from '@/components/ui/button'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
-import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
-import { campaignDemandKindLabels, campaignDemandKinds } from '@/lib/schemas/campaignDemand'
+import {
+ CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION,
+ CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION,
+ CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL,
+ CAMPAIGN_DEMAND_ACTIVITY_LABEL,
+ CAMPAIGN_DEMAND_BODY_DESCRIPTION,
+ CAMPAIGN_DEMAND_BODY_LABEL,
+ CAMPAIGN_DEMAND_BODY_MAX_LENGTH,
+ CAMPAIGN_DEMAND_BODY_PLACEHOLDER,
+ CAMPAIGN_DEMAND_KIND_LABEL,
+ CAMPAIGN_DEMAND_SUBMIT_LABEL,
+ campaignDemandKindLabels,
+ campaignDemandKinds,
+} from '@/lib/schemas/campaignDemand'
import type { ActivityRelationOption } from '@/utilities/activityRelationOptions'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
@@ -56,28 +68,14 @@ export const DemandForm = ({
)
const activityFieldDescription = parsedMunicipalityId
- ? 'Opcional. Busque atividades do município selecionado.'
+ ? CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION
: 'Opcional. Escolha o município antes de vincular uma atividade.'
return (
)
diff --git a/src/components/campaign/shared/WizardMunicipalitySelectedStub.tsx b/src/components/campaign/shared/WizardMunicipalitySelectedStub.tsx
deleted file mode 100644
index 11b9882c..00000000
--- a/src/components/campaign/shared/WizardMunicipalitySelectedStub.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
-import { wizardActionHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
-import {
- wizardFlowTitleForSlug,
- wizardNextStepPlaceholder,
- wizardNextStepTitle,
-} from '@/lib/campaignWizardCopy'
-
-type WizardMunicipalitySelectedStubProps = {
- actionSlug: string
- municipalityName: string
- returnPath?: string
-}
-
-export const WizardMunicipalitySelectedStub = ({
- actionSlug,
- municipalityName,
- returnPath,
-}: WizardMunicipalitySelectedStubProps) => (
-
-
{wizardNextStepPlaceholder(actionSlug)}
-
-)
diff --git a/src/components/campaign/shared/WizardRegisterDemandStep.tsx b/src/components/campaign/shared/WizardRegisterDemandStep.tsx
new file mode 100644
index 00000000..b1bc0f71
--- /dev/null
+++ b/src/components/campaign/shared/WizardRegisterDemandStep.tsx
@@ -0,0 +1,166 @@
+'use client'
+
+import { useRouter } from 'next/navigation'
+import { useActionState, useCallback, useState } from 'react'
+
+import { createWizardDemandFormAction } from '@/app/(campaign)/campanha/(app)/acoes/formActions'
+import { searchDemandActivityOptions } from '@/app/(campaign)/campanha/(app)/demandas/activitySearchActions'
+import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
+import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
+import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
+import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
+import { Button } from '@/components/ui/button'
+import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
+import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+import { Spinner } from '@/components/ui/Spinner'
+import { Textarea } from '@/components/ui/textarea'
+import { wizardPreviousHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
+import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
+import { WIZARD_DEMAND_PENDING_ARIA, wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
+import {
+ CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION,
+ CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION,
+ CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL,
+ CAMPAIGN_DEMAND_ACTIVITY_LABEL,
+ CAMPAIGN_DEMAND_BODY_DESCRIPTION,
+ CAMPAIGN_DEMAND_BODY_LABEL,
+ CAMPAIGN_DEMAND_BODY_MAX_LENGTH,
+ CAMPAIGN_DEMAND_BODY_PLACEHOLDER,
+ CAMPAIGN_DEMAND_KIND_LABEL,
+ CAMPAIGN_DEMAND_SUBMIT_LABEL,
+ campaignDemandKindLabels,
+ campaignDemandKinds,
+} from '@/lib/schemas/campaignDemand'
+
+type WizardRegisterDemandStepProps = {
+ actionSlug: string
+ municipalityId: number
+ municipalityName: string
+ municipalitySlug: string
+ returnPath?: string
+}
+
+/**
+ * Final step of the "Registrar pedido" wizard (A5/B195): the demand form
+ * inside the wizard shell — municipality in the app header (no step title, no
+ * municipality selector), single free-text field, save → toast → origin.
+ */
+export const WizardRegisterDemandStep = ({
+ actionSlug,
+ municipalityId,
+ municipalityName,
+ municipalitySlug,
+ returnPath,
+}: WizardRegisterDemandStepProps) => {
+ const router = useRouter()
+ const [state, submitAction, isPending] = useActionState(createWizardDemandFormAction, {})
+ const [activity, setActivity] = useState<{ id: number; label: string } | null>(null)
+
+ const searchActivities = useCallback(
+ (query: string) => searchDemandActivityOptions(query, municipalityId),
+ [municipalityId],
+ )
+
+ useCampaignFormSuccessToast(state, () => {
+ recordLastActedMunicipality(municipalitySlug)
+ router.push(wizardReturnHref(returnPath))
+ })
+
+ return (
+
+
+
+ )
+}
diff --git a/src/components/campaign/shared/useCampaignFormSuccessToast.ts b/src/components/campaign/shared/useCampaignFormSuccessToast.ts
index 3bcc8a34..702dc06e 100644
--- a/src/components/campaign/shared/useCampaignFormSuccessToast.ts
+++ b/src/components/campaign/shared/useCampaignFormSuccessToast.ts
@@ -7,7 +7,10 @@ import { toast } from 'sonner'
* The success→toast effect every form-action consumer spelled by hand (P3-G,
* ×7): fires once per state change, then runs the caller's follow-up (form
* reset, `router.refresh()`, navigation) through a ref so a re-rendered
- * callback never re-fires the toast.
+ * callback never re-fires the toast. Depends on the state object's identity:
+ * `useActionState` hands back a fresh object per submission, so a second save
+ * with the same message (e.g. the inline demand-description editor) still
+ * fires — an object identity the message/status fields cannot express.
*/
export const useCampaignFormSuccessToast = (
state: { status?: string; message?: string },
@@ -22,7 +25,5 @@ export const useCampaignFormSuccessToast = (
if (state.status !== 'success') return
toast.success(state.message)
onSuccessRef.current?.()
- // `state.message` is part of the change the toast reports — both belong
- // to the firing condition, like the seven hand-written effects it replaces.
- }, [state.message, state.status])
+ }, [state])
}
diff --git a/src/lib/campaignActionRoutes.ts b/src/lib/campaignActionRoutes.ts
index 6617f986..771a241d 100644
--- a/src/lib/campaignActionRoutes.ts
+++ b/src/lib/campaignActionRoutes.ts
@@ -188,6 +188,7 @@ type WizardStepKind =
| 'update-body'
| 'leadership-grid'
| 'leadership-form'
+ | 'register-demand'
type WizardPreviousHrefInput = {
actionSlug: string
diff --git a/src/lib/campaignWizardCopy.ts b/src/lib/campaignWizardCopy.ts
index 3303e136..dbb88ce5 100644
--- a/src/lib/campaignWizardCopy.ts
+++ b/src/lib/campaignWizardCopy.ts
@@ -32,7 +32,9 @@ export const WIZARD_VOTES_SAVED_MESSAGE = 'Votos estimados atualizados.' as cons
export const WIZARD_VOTES_FINAL_CTA_LABEL = 'Salvar estimativas →' as const
-const WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER = 'Próximo passo deste fluxo em breve.' as const
+export const WIZARD_DEMAND_SAVED_MESSAGE = 'Demanda aberta.' as const
+
+export const WIZARD_DEMAND_PENDING_ARIA = 'Abrindo demanda.' as const
export const wizardNextStepTitle = (actionSlug: string): string => {
if (actionSlug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-votes']) {
@@ -41,9 +43,6 @@ export const wizardNextStepTitle = (actionSlug: string): string => {
return 'Continuar'
}
-export const wizardNextStepPlaceholder = (_actionSlug: string): string =>
- WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER
-
export const WIZARD_LEADERSHIP_GRID_TITLE = 'Quem coordena por aqui?' as const
export const WIZARD_LEADERSHIP_FORM_CREATE_TITLE = 'Nova liderança' as const
diff --git a/src/lib/demandTitle.ts b/src/lib/demandTitle.ts
new file mode 100644
index 00000000..cf2d4e19
--- /dev/null
+++ b/src/lib/demandTitle.ts
@@ -0,0 +1,24 @@
+import { slugify } from '@/lib/slug'
+
+export const DEMAND_TITLE_MAX_LENGTH = 160
+
+/**
+ * A derived demand title is usable when it fits the collection constraints
+ * (minLength 2 / maxLength 160) and still produces a non-empty URL slug —
+ * the same letters-or-numbers requirement `setCanonicalDemandSlug` enforces.
+ */
+export const isUsableDemandTitle = (title: string): boolean => {
+ const trimmed = title.trim()
+ return (
+ trimmed.length >= 2 && trimmed.length <= DEMAND_TITLE_MAX_LENGTH && slugify(trimmed).length > 0
+ )
+}
+
+/**
+ * Deterministic fallback for the AI-derived demand title (B195): the single
+ * free-text field collapsed to one line and truncated to the title bound.
+ * Never fails — garbage in produces a garbage title, and the collection's
+ * own letters-or-numbers check rejects it with the usual message.
+ */
+export const fallbackDemandTitle = (description: string): string =>
+ description.trim().replace(/\s+/g, ' ').slice(0, DEMAND_TITLE_MAX_LENGTH).trim()
diff --git a/src/lib/schemas/activity.ts b/src/lib/schemas/activity.ts
index d3cc4786..3198edb0 100644
--- a/src/lib/schemas/activity.ts
+++ b/src/lib/schemas/activity.ts
@@ -2,7 +2,7 @@ import { z } from 'zod'
import { allDayRangeValid } from '@/lib/activityAllDay'
import { relationshipId } from '@/lib/relationship'
-import { campaignDemandCreateSchema } from '@/lib/schemas/campaignDemandInput'
+import { CAMPAIGN_DEMAND_BODY_MAX_LENGTH, campaignDemandKinds } from '@/lib/schemas/campaignDemand'
import {
positiveRelationshipId,
trimmedNullableText,
@@ -51,10 +51,15 @@ export const MAX_ACTIVITY_DEMAND_DRAFTS = 20
/** C90 — cap for the polymorphic multi-value `responsible` field. */
export const MAX_ACTIVITY_RESPONSIBLES = 20
-const activityDemandDraftSchema = campaignDemandCreateSchema.pick({
- title: true,
- kind: true,
- description: true,
+/**
+ * Draft demand born with an activity (C90). Kept as the legacy title + free
+ * text shape: the activity vertical is out of B195 scope, and its draft
+ * titles are staff-curated short names, not AI-derived ones.
+ */
+const activityDemandDraftSchema = z.object({
+ title: z.string().trim().min(2).max(160),
+ kind: z.enum(campaignDemandKinds),
+ description: trimmedOptionalText(CAMPAIGN_DEMAND_BODY_MAX_LENGTH),
})
export const activityDemandDraftsSchema = z
diff --git a/src/lib/schemas/campaignDemand.ts b/src/lib/schemas/campaignDemand.ts
index a7065313..2347ed0d 100644
--- a/src/lib/schemas/campaignDemand.ts
+++ b/src/lib/schemas/campaignDemand.ts
@@ -24,6 +24,25 @@ export const campaignDemandKindLabels: Record = {
outro: 'Outro',
}
+/**
+ * Shared demand-form copy (B195 single free-text field): used by the wizard
+ * step and the `/demandas/nova` form so the two cadastros never diverge.
+ */
+export const CAMPAIGN_DEMAND_BODY_LABEL = 'O que você precisa?' as const
+export const CAMPAIGN_DEMAND_BODY_DESCRIPTION =
+ 'Descreva o que você precisa, com o quê e os detalhes.' as const
+export const CAMPAIGN_DEMAND_BODY_PLACEHOLDER =
+ 'Ex.: 500 santinhos para a feira de sábado; transporte para o comício; diária da equipe…' as const
+export const CAMPAIGN_DEMAND_BODY_MAX_LENGTH = 4000 as const
+export const CAMPAIGN_DEMAND_KIND_LABEL = 'Tipo' as const
+export const CAMPAIGN_DEMAND_ACTIVITY_LABEL = 'Atividade relacionada' as const
+export const CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION =
+ 'Opcional. Busque atividades do município selecionado.' as const
+export const CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL = 'Nenhuma atividade' as const
+export const CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION =
+ 'Busque atividades do município selecionado por título.' as const
+export const CAMPAIGN_DEMAND_SUBMIT_LABEL = 'Abrir demanda' as const
+
export const campaignDemandStatuses = [
'aberta',
'em_analise',
@@ -72,6 +91,8 @@ export const CAMPAIGN_DEMAND_ESCALATED_DECISION_MESSAGE =
'Demandas escaladas são decididas pelo Coordenador Geral ou Candidato.'
export const CAMPAIGN_DEMAND_COST_STAFF_MESSAGE =
'Somente a coordenação, a assessoria e o candidato registram custos.'
+export const CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE =
+ 'Somente a coordenação, a assessoria e o candidato editam demandas.'
export const CAMPAIGN_DEMAND_RECEIPT_STAFF_MESSAGE =
'Somente a coordenação e a assessoria anexam comprovantes.'
export const CAMPAIGN_DEMAND_RECEIPT_TYPE_MESSAGE = 'Envie uma imagem (JPEG, PNG, WebP) ou PDF.'
diff --git a/src/lib/schemas/campaignDemandInput.ts b/src/lib/schemas/campaignDemandInput.ts
index 2e009e7d..9f361b99 100644
--- a/src/lib/schemas/campaignDemandInput.ts
+++ b/src/lib/schemas/campaignDemandInput.ts
@@ -1,22 +1,30 @@
import { z } from 'zod'
-import { campaignDemandKinds, campaignDemandStatuses } from '@/lib/schemas/campaignDemand'
import {
- positiveRelationshipId,
- trimmedNullableText,
- trimmedOptionalText,
-} from '@/lib/schemas/primitives'
+ CAMPAIGN_DEMAND_BODY_MAX_LENGTH,
+ campaignDemandKinds,
+ campaignDemandStatuses,
+} from '@/lib/schemas/campaignDemand'
+import { positiveRelationshipId, trimmedNullableText } from '@/lib/schemas/primitives'
export const campaignDemandCreateSchema = z.object({
- title: z.string().trim().min(2).max(160),
kind: z.enum(campaignDemandKinds),
- description: trimmedOptionalText(4000),
+ /**
+ * The single free-text field (B195): what + details. `title` is derived
+ * server-side by AI with a truncation fallback — never sent by the forms.
+ */
+ description: z.string().trim().min(2).max(CAMPAIGN_DEMAND_BODY_MAX_LENGTH),
municipality: positiveRelationshipId,
activity: positiveRelationshipId.optional(),
/** Staff may record on behalf of a leadership; leaders get their own. */
leadership: positiveRelationshipId.optional(),
})
+export const campaignDemandUpdateSchema = z.object({
+ id: positiveRelationshipId,
+ description: z.string().trim().min(2).max(CAMPAIGN_DEMAND_BODY_MAX_LENGTH),
+})
+
export const campaignDemandTransitionSchema = z.object({
id: positiveRelationshipId,
status: z.enum(campaignDemandStatuses),
@@ -29,5 +37,6 @@ export const campaignDemandCostSchema = z.object({
})
export type CampaignDemandCreateInput = z.input
+export type CampaignDemandUpdateInput = z.input
export type CampaignDemandTransitionInput = z.input
export type CampaignDemandCostInput = z.input
diff --git a/src/utilities/ai/campaignDemandTitle.ts b/src/utilities/ai/campaignDemandTitle.ts
new file mode 100644
index 00000000..fe68eedf
--- /dev/null
+++ b/src/utilities/ai/campaignDemandTitle.ts
@@ -0,0 +1,44 @@
+import 'server-only'
+
+import { deepSeek } from '@ai-sdk/deepseek'
+import { generateText } from 'ai'
+
+import { isUsableDemandTitle } from '@/lib/demandTitle'
+import { campaignDemandKindLabels, type CampaignDemandKind } from '@/lib/schemas/campaignDemand'
+
+const DEMAND_TITLE_SYSTEM_PROMPT =
+ 'Você resume pedidos de campanha eleitoral em títulos curtos e objetivos para uma lista de demandas. ' +
+ 'Responda apenas com o título: uma frase de no máximo 80 caracteres, sem pontuação final, sem aspas, sem prefixos ' +
+ 'como "Pedido:" ou "Demanda:". Ex.: "500 santinhos para a feira de sábado", "Transporte para o comício do dia 12".'
+
+const DEMAND_TITLE_TIMEOUT_MS = 4000
+
+/**
+ * One-shot AI title derivation for campaign demands (B195), on the Sollinha
+ * path: DeepSeek server-side via `DEEPSEEK_API_KEY`. Never throws and never
+ * blocks creation: missing key, timeout or unusable output all resolve to
+ * `null`, and the caller owns the fallback policy (truncation on create,
+ * keep-previous on edit).
+ */
+export const deriveDemandTitle = async (
+ description: string,
+ kind?: CampaignDemandKind,
+): Promise => {
+ if (!process.env.DEEPSEEK_API_KEY) return null
+
+ try {
+ const { text } = await generateText({
+ model: deepSeek('deepseek-v4-flash'),
+ system: DEMAND_TITLE_SYSTEM_PROMPT,
+ prompt: kind
+ ? `Tipo: ${campaignDemandKindLabels[kind]}\nPedido: ${description}`
+ : description,
+ temperature: 0.3,
+ maxOutputTokens: 120,
+ abortSignal: AbortSignal.timeout(DEMAND_TITLE_TIMEOUT_MS),
+ })
+ return isUsableDemandTitle(text) ? text.trim() : null
+ } catch {
+ return null
+ }
+}
diff --git a/src/utilities/demand/campaignDemandFormData.ts b/src/utilities/demand/campaignDemandFormData.ts
new file mode 100644
index 00000000..54355ea6
--- /dev/null
+++ b/src/utilities/demand/campaignDemandFormData.ts
@@ -0,0 +1,26 @@
+import {
+ nullableRelationshipFormValue,
+ requiredFormText,
+ requiredRelationshipFormValue,
+} from '@/lib/formData'
+import { campaignDemandKinds, type CampaignDemandKind } from '@/lib/schemas/campaignDemand'
+import type { CampaignDemandCreateInput } from '@/lib/schemas/campaignDemandInput'
+
+/**
+ * Shared create-form parsing for the two demand cadastros (wizard step and
+ * `/demandas/nova`): same free-text field, kind coercion and municipality /
+ * activity relationships — the actions only differ in the response wrapper.
+ */
+export const parseCampaignDemandCreateFormData = (
+ formData: FormData,
+): CampaignDemandCreateInput => {
+ const rawKind = requiredFormText(formData, 'kind')
+ return {
+ kind: campaignDemandKinds.includes(rawKind as CampaignDemandKind)
+ ? (rawKind as CampaignDemandKind)
+ : 'outro',
+ description: requiredFormText(formData, 'description'),
+ municipality: requiredRelationshipFormValue(formData, 'municipalityId'),
+ activity: nullableRelationshipFormValue(formData, 'activityId') ?? undefined,
+ }
+}
diff --git a/tests/e2e/campaignRegisterDemand.e2e.spec.ts b/tests/e2e/campaignRegisterDemand.e2e.spec.ts
new file mode 100644
index 00000000..3d9b4407
--- /dev/null
+++ b/tests/e2e/campaignRegisterDemand.e2e.spec.ts
@@ -0,0 +1,89 @@
+import { fallbackDemandTitle } from '../../src/lib/demandTitle.js'
+import {
+ CAMPAIGN_DEMAND_ACTIVITY_LABEL,
+ CAMPAIGN_DEMAND_BODY_LABEL,
+ CAMPAIGN_DEMAND_KIND_LABEL,
+} from '../../src/lib/schemas/campaignDemand.js'
+import { expect, test } from './fixtures/campaignE2EFixtures.js'
+
+test.use({ viewport: { width: 390, height: 844 } })
+
+test.describe('Wizard "Registrar pedido" (A5/B195)', () => {
+ test('creates a demand from the final step and returns to the origin', async ({
+ campaign,
+ page,
+ }) => {
+ const { fixtures } = campaign
+ const coordinator = await fixtures.createCampaignUser('coordinator', {
+ name: fixtures.value('Coordenadora Geral'),
+ })
+ const municipality = await fixtures.claimMunicipality()
+ const description = fixtures.value(
+ '500 santinhos para a feira de sábado; precisa de transporte para o comício.',
+ )
+
+ await campaign.login(page, coordinator.email!, coordinator.password)
+ await page.goto(`/campanha/acoes/registrar-pedido?municipio=${municipality.slug}`)
+
+ const topBar = page.locator('[data-slot="campaign-mobile-top-bar"][data-mode="wizard"]')
+ await expect(topBar.getByText('Registrar pedido', { exact: true })).toBeVisible()
+ await expect(
+ topBar.getByLabel(new RegExp(`^Município em atualização: ${municipality.name}`, 'i')),
+ ).toBeVisible()
+
+ // No big step title, no title input, no municipality selector: the single
+ // free-text field comes straight after Tipo and Atividade.
+ await expect(page.getByRole('heading', { name: 'Registrar pedido' })).toHaveCount(0)
+ await expect(page.getByLabel(CAMPAIGN_DEMAND_KIND_LABEL)).toBeVisible()
+ await expect(page.getByLabel(CAMPAIGN_DEMAND_ACTIVITY_LABEL)).toBeVisible()
+ await expect(page.getByLabel(CAMPAIGN_DEMAND_BODY_LABEL)).toBeVisible()
+ await expect(page.locator('input[name="title"]')).toHaveCount(0)
+ await expect(page.locator('select[name="municipalityId"]')).toHaveCount(0)
+
+ await page.getByLabel(CAMPAIGN_DEMAND_BODY_LABEL).fill(description)
+ await page.getByRole('button', { name: 'Abrir demanda' }).click()
+
+ await page.waitForURL(/\/campanha\/?$/)
+
+ // The demand born from the wizard carries the derived title in the demands
+ // list: without a DEEPSEEK_API_KEY in the e2e app that is the truncated
+ // free text — asserted via the contract, not the fixture's length.
+ await page.goto('/campanha/demandas')
+ await expect(page.getByRole('link', { name: fallbackDemandTitle(description) })).toBeVisible()
+ })
+
+ test('goes back to the municipality search of its own flow', async ({ campaign, page }) => {
+ const { fixtures } = campaign
+ const coordinator = await fixtures.createCampaignUser('coordinator', {
+ name: fixtures.value('Coordenadora Geral'),
+ })
+ const municipality = await fixtures.claimMunicipality()
+
+ await campaign.login(page, coordinator.email!, coordinator.password)
+ await page.goto(`/campanha/acoes/registrar-pedido?municipio=${municipality.slug}`)
+
+ await expect(page.getByLabel(CAMPAIGN_DEMAND_BODY_LABEL)).toBeVisible()
+ await page.getByRole('link', { name: /Voltar/ }).click()
+
+ await page.waitForURL(/\/campanha\/acoes\/registrar-pedido\/?$/)
+ await expect(page.getByLabel('Buscar município')).toBeVisible()
+ })
+
+ test('keeps the final step away from leaders', async ({ campaign, page }) => {
+ const { fixtures } = campaign
+ const leader = await fixtures.createCampaignUser('leader', {
+ name: fixtures.value('Líder da comunidade'),
+ })
+ const municipality = await fixtures.claimMunicipality()
+
+ await campaign.login(page, leader.email!, leader.password)
+ // The leader lockdown redirects to the contacts home; the redirect aborts
+ // `goto`'s load event (ERR_ABORTED), so the redirect itself is asserted
+ // the same way the municipality pages do (B43).
+ await page
+ .goto(`${campaign.baseURL}/campanha/acoes/registrar-pedido?municipio=${municipality.slug}`)
+ .catch(() => {})
+ await expect(page).toHaveURL(`${campaign.baseURL}/campanha/contatos`)
+ await expect(page.getByLabel(CAMPAIGN_DEMAND_BODY_LABEL)).toHaveCount(0)
+ })
+})
diff --git a/tests/int/campaignDemandWorkflow.int.spec.ts b/tests/int/campaignDemandWorkflow.int.spec.ts
index de44a835..f851ed72 100644
--- a/tests/int/campaignDemandWorkflow.int.spec.ts
+++ b/tests/int/campaignDemandWorkflow.int.spec.ts
@@ -8,7 +8,9 @@ import {
createCampaignDemandRecord,
setCampaignDemandCostRecord,
transitionCampaignDemandRecord,
+ updateCampaignDemandRecord,
} from '@/app/(campaign)/campanha/actions/demand'
+import { slugify } from '@/lib/slug'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
@@ -23,6 +25,9 @@ const campaignFixtures = installCampaignFixtures({
describe('campaign demand workflow (staff-only)', () => {
beforeAll(async () => {
+ // The derived-title assertions below only hold while the AI call is
+ // skipped: fail loudly if a key ever appears in the test environment.
+ expect(process.env.DEEPSEEK_API_KEY).toBeUndefined()
payload = await getPayload({ config: await config })
})
@@ -46,18 +51,24 @@ describe('campaign demand workflow (staff-only)', () => {
return { fixtures, municipality, coordinator, advisor, candidate, leaderAccount, leadership }
}
- it('lets staff open a demand in an administered municipality', async () => {
+ it('lets staff open a demand in an administered municipality with a derived title', async () => {
const { fixtures, municipality, advisor } = await createScenario()
+ const description = fixtures.value(
+ 'Som para caminhada: precisamos de carro de som para a caminhada de sábado.',
+ )
const demand = await createCampaignDemandRecord(payload, advisor, {
- title: fixtures.value('Som para caminhada'),
kind: 'equipamento',
- description: 'Precisamos de carro de som para a caminhada de sábado.',
+ description,
municipality: municipality.id,
})
fixtures.own('campaignDemand', demand.id)
expect(demand.status).toBe('aberta')
+ // Without a DEEPSEEK_API_KEY in tests, the derived title falls back to the
+ // truncated free text — creation never fails over the AI.
+ expect(demand.title).toBe(description)
+ expect(demand.slug).toBe(slugify(description))
const raw = await payload.findByID({
collection: 'campaignDemand',
@@ -69,13 +80,53 @@ describe('campaign demand workflow (staff-only)', () => {
expect(raw.statusHistory?.[0]?.status).toBe('aberta')
})
+ it('suffixes the slug when the derived title collides', async () => {
+ const { fixtures, municipality, advisor } = await createScenario()
+
+ const description = fixtures.value('Material para a feira: 500 santinhos e 200 bottons.')
+ const first = await createCampaignDemandRecord(payload, advisor, {
+ kind: 'material',
+ description,
+ municipality: municipality.id,
+ })
+ const second = await createCampaignDemandRecord(payload, advisor, {
+ kind: 'material',
+ description,
+ municipality: municipality.id,
+ })
+ const third = await createCampaignDemandRecord(payload, advisor, {
+ kind: 'material',
+ description,
+ municipality: municipality.id,
+ })
+ fixtures.own('campaignDemand', first.id)
+ fixtures.own('campaignDemand', second.id)
+ fixtures.own('campaignDemand', third.id)
+
+ expect(second.title).toBe(first.title)
+ expect(second.slug).toBe(`${first.slug}-2`)
+ expect(third.slug).toBe(`${first.slug}-3`)
+ })
+
+ it('rejects a free text shorter than the two-character minimum', async () => {
+ const { municipality, advisor } = await createScenario()
+
+ await expect(
+ createCampaignDemandRecord(payload, advisor, {
+ kind: 'material',
+ description: 'a',
+ municipality: municipality.id,
+ }),
+ ).rejects.toThrow()
+ })
+
it('rejects demand creation from leaders', async () => {
const { fixtures, municipality, leaderAccount } = await createScenario()
await expect(
createCampaignDemandRecord(payload, leaderAccount, {
- title: fixtures.value('Bloqueado'),
kind: 'material',
+ description: fixtures.value('Bloqueado'),
municipality: municipality.id,
}),
).rejects.toThrow()
@@ -88,8 +139,8 @@ describe('campaign demand workflow (staff-only)', () => {
await expect(
createCampaignDemandRecord(payload, outsideAdvisor, {
- title: fixtures.value('Fora do escopo'),
kind: 'material',
+ description: fixtures.value('Fora do escopo'),
municipality: otherMunicipality.id,
}),
).rejects.toThrow()
@@ -99,8 +150,8 @@ describe('campaign demand workflow (staff-only)', () => {
const { fixtures, municipality, coordinator, advisor, candidate } = await createScenario()
const demand = await createCampaignDemandRecord(payload, advisor, {
- title: fixtures.value('Transporte'),
kind: 'transporte',
+ description: fixtures.value('Transporte'),
municipality: municipality.id,
})
fixtures.own('campaignDemand', demand.id)
@@ -135,8 +186,8 @@ describe('campaign demand workflow (staff-only)', () => {
expect(approved.status).toBe('aprovada')
const demandForCandidate = await createCampaignDemandRecord(payload, advisor, {
- title: fixtures.value('Candidato decide'),
kind: 'transporte',
+ description: fixtures.value('Candidato decide'),
municipality: municipality.id,
})
fixtures.own('campaignDemand', demandForCandidate.id)
@@ -162,8 +213,8 @@ describe('campaign demand workflow (staff-only)', () => {
const { fixtures, municipality, advisor, leaderAccount } = await createScenario()
const demand = await createCampaignDemandRecord(payload, advisor, {
- title: fixtures.value('Faixas'),
kind: 'material',
+ description: fixtures.value('Faixas'),
municipality: municipality.id,
})
fixtures.own('campaignDemand', demand.id)
@@ -184,8 +235,8 @@ describe('campaign demand workflow (staff-only)', () => {
const { fixtures, municipality, advisor } = await createScenario()
const demand = await createCampaignDemandRecord(payload, advisor, {
- title: fixtures.value('Aluguel de espaço'),
kind: 'espaco',
+ description: fixtures.value('Aluguel de espaço'),
municipality: municipality.id,
})
fixtures.own('campaignDemand', demand.id)
@@ -207,8 +258,8 @@ describe('campaign demand workflow (staff-only)', () => {
const outsideAdvisor = await fixtures.createCampaignUser('advisor')
const demand = await createCampaignDemandRecord(payload, advisor, {
- title: fixtures.value('Panfletos'),
kind: 'material',
+ description: fixtures.value('Panfletos'),
municipality: municipality.id,
})
fixtures.own('campaignDemand', demand.id)
@@ -223,4 +274,68 @@ describe('campaign demand workflow (staff-only)', () => {
})
expect(visible.docs).toHaveLength(0)
})
+
+ it('keeps the previous title and slug when the edited description has no AI', async () => {
+ const { fixtures, municipality, advisor } = await createScenario()
+
+ const demand = await createCampaignDemandRecord(payload, advisor, {
+ kind: 'material',
+ description: fixtures.value('Panfletos da feira'),
+ municipality: municipality.id,
+ })
+ fixtures.own('campaignDemand', demand.id)
+ const originalSlug = demand.slug
+ const originalTitle = demand.title
+
+ const updated = await updateCampaignDemandRecord(payload, advisor, {
+ id: demand.id,
+ description: `${fixtures.value('Panfletos')} — agora com mais 200 unidades.`,
+ })
+
+ // AI is unavailable in tests: the previous title is kept (keep-previous
+ // policy for edits) and the canonical slug never changes after create.
+ expect(updated.description).toContain('mais 200 unidades')
+ expect(updated.title).toBe(originalTitle)
+ expect(updated.slug).toBe(originalSlug)
+ })
+
+ it('recalculates nothing on an unchanged description', async () => {
+ const { fixtures, municipality, advisor } = await createScenario()
+
+ const demand = await createCampaignDemandRecord(payload, advisor, {
+ kind: 'material',
+ description: fixtures.value('Mesma descrição'),
+ municipality: municipality.id,
+ })
+ fixtures.own('campaignDemand', demand.id)
+ const originalTitle = demand.title
+ const originalSlug = demand.slug
+
+ const updated = await updateCampaignDemandRecord(payload, advisor, {
+ id: demand.id,
+ description: demand.description ?? '',
+ })
+
+ expect(updated.title).toBe(originalTitle)
+ expect(updated.slug).toBe(originalSlug)
+ expect(updated.description).toBe(demand.description)
+ })
+
+ it('rejects description edits from leaders', async () => {
+ const { fixtures, municipality, advisor, leaderAccount } = await createScenario()
+
+ const demand = await createCampaignDemandRecord(payload, advisor, {
+ kind: 'material',
+ description: fixtures.value('Bloqueado para líder'),
+ municipality: municipality.id,
+ })
+ fixtures.own('campaignDemand', demand.id)
+
+ await expect(
+ updateCampaignDemandRecord(payload, leaderAccount, {
+ id: demand.id,
+ description: fixtures.value('Tentativa do líder'),
+ }),
+ ).rejects.toThrow(/editam demandas/)
+ })
})
diff --git a/tests/unit/demandTitle.unit.spec.ts b/tests/unit/demandTitle.unit.spec.ts
new file mode 100644
index 00000000..eab83fb1
--- /dev/null
+++ b/tests/unit/demandTitle.unit.spec.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ DEMAND_TITLE_MAX_LENGTH,
+ fallbackDemandTitle,
+ isUsableDemandTitle,
+} from '@/lib/demandTitle'
+
+describe('fallbackDemandTitle', () => {
+ it('collapses whitespace to a single line', () => {
+ expect(fallbackDemandTitle(' Panfletos para a\nfeira de sábado ')).toBe(
+ 'Panfletos para a feira de sábado',
+ )
+ })
+
+ it('truncates to the title bound', () => {
+ const long = 'x'.repeat(DEMAND_TITLE_MAX_LENGTH + 50)
+ expect(fallbackDemandTitle(long)).toHaveLength(DEMAND_TITLE_MAX_LENGTH)
+ })
+
+ it('strips whitespace left behind by the slice', () => {
+ const value = `${'y'.repeat(DEMAND_TITLE_MAX_LENGTH - 1)} zzz`
+ expect(fallbackDemandTitle(value)).toBe('y'.repeat(DEMAND_TITLE_MAX_LENGTH - 1))
+ })
+
+ it('keeps empty input empty (the collection letters-or-numbers check rejects it)', () => {
+ expect(fallbackDemandTitle(' ')).toBe('')
+ })
+})
+
+describe('isUsableDemandTitle', () => {
+ it('accepts a short descriptive title', () => {
+ expect(isUsableDemandTitle('500 santinhos para a feira')).toBe(true)
+ })
+
+ it('accepts the exact window edges', () => {
+ expect(isUsableDemandTitle('ab')).toBe(true)
+ expect(isUsableDemandTitle('x'.repeat(DEMAND_TITLE_MAX_LENGTH))).toBe(true)
+ })
+
+ it('rejects punctuation-only output (no URL slug)', () => {
+ expect(isUsableDemandTitle('???')).toBe(false)
+ })
+
+ it('rejects a title beyond the bound', () => {
+ expect(isUsableDemandTitle('x'.repeat(DEMAND_TITLE_MAX_LENGTH + 1))).toBe(false)
+ })
+
+ it('rejects a single character', () => {
+ expect(isUsableDemandTitle('a')).toBe(false)
+ })
+})