feat(B195): finalizar wizard Registrar pedido — campo único + título derivado por IA + edição no detalhe

This commit is contained in:
Francisco Solla 2026-08-10 19:23:09 -03:00
parent f460dac6cf
commit 712d40f83f
26 changed files with 1076 additions and 123 deletions

View file

@ -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 <N>` 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 <N>` (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:<base>` 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).

View file

@ -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).

View file

@ -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<string | null>` — `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:<id>')` — 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/<slug-descritivo>` 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) → `<WizardRegisterDemandStep municipalityId/Name/Slug …>`. 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)

View file

@ -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 (
<WizardMunicipalitySelectedStub
actionSlug={slug}
municipalityName={municipality.name}
returnPath={returnPath}
/>
)
if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['register-demand']) {
return (
<WizardRegisterDemandStep
actionSlug={slug}
municipalityId={municipality.id}
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
returnPath={returnPath}
/>
)
}
notFound()
}

View file

@ -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<CampaignFormActionState> =>
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.',
})

View file

@ -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<CampaignFormActionState> =>
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,

View file

@ -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))}
</p>
{demand.description ? (
<section aria-label="Descrição da demanda" className="rounded-xl border p-4">
<p className="whitespace-pre-wrap text-sm">{demand.description}</p>
</section>
) : null}
<DemandDescriptionEditor
demandId={demand.id}
initialDescription={demand.description ?? ''}
formAction={updateDemandFormAction}
/>
{demand.decisionNote && (demand.status === 'aprovada' || demand.status === 'rejeitada') ? (
<section aria-label="Decisão" className="rounded-xl border p-4">

View file

@ -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<CampaignFormActionState> =>
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.',
})

View file

@ -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)

View file

@ -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: [

View file

@ -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<CampaignFormActionState>
}
/**
* 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 (
<section aria-label="Descrição da demanda" className="rounded-xl border p-4">
<div className="flex items-start justify-between gap-2">
<p className="whitespace-pre-wrap text-sm">{initialDescription}</p>
<Button type="button" variant="ghost" onClick={() => setEditing(true)}>
{initialDescription ? 'Editar' : 'Adicionar descrição'}
</Button>
</div>
</section>
)
}
return (
<section aria-label="Editar descrição da demanda" className="rounded-xl border p-4">
<form
action={submitAction}
className="flex flex-col gap-4"
aria-busy={isPending || undefined}
data-pending={isPending ? '' : undefined}
>
<input type="hidden" name="demandId" value={demandId} />
<Field>
<FieldLabel htmlFor="demand-description-edit">{CAMPAIGN_DEMAND_BODY_LABEL}</FieldLabel>
<Textarea
id="demand-description-edit"
name="description"
defaultValue={initialDescription}
rows={5}
minLength={2}
maxLength={CAMPAIGN_DEMAND_BODY_MAX_LENGTH}
required
disabled={isPending}
className="min-h-28"
/>
{state.fieldErrors?.description ? (
<FieldError>{state.fieldErrors.description}</FieldError>
) : null}
</Field>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => setEditing(false)}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending} className="min-h-11">
{isPending ? (
<>
<Spinner data-icon="inline-start" aria-hidden="true" />
Salvando…
</>
) : (
'Salvar'
)}
</Button>
</div>
<div aria-live="polite" className="sr-only">
{isPending ? 'Salvando descrição.' : null}
</div>
</form>
</section>
)
}

View file

@ -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 (
<form action={submitAction} className="flex max-w-2xl flex-col gap-4">
<Field>
<FieldLabel htmlFor="demand-title">O que você precisa?</FieldLabel>
<Input
id="demand-title"
name="title"
required
minLength={2}
maxLength={160}
className="min-h-11"
/>
{fieldError(state.fieldErrors, 'title') ? (
<FieldError>{fieldError(state.fieldErrors, 'title')}</FieldError>
) : null}
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="demand-kind">Tipo</FieldLabel>
<FieldLabel htmlFor="demand-kind">{CAMPAIGN_DEMAND_KIND_LABEL}</FieldLabel>
<NativeSelect
id="demand-kind"
name="kind"
@ -119,14 +117,14 @@ export const DemandForm = ({
</Field>
</div>
<Field>
<FieldLabel htmlFor="demand-activity">Atividade relacionada</FieldLabel>
<FieldLabel>{CAMPAIGN_DEMAND_ACTIVITY_LABEL}</FieldLabel>
{parsedMunicipalityId ? (
<AsyncSearchCombobox
name="activityId"
label="Atividade relacionada"
label={CAMPAIGN_DEMAND_ACTIVITY_LABEL}
value={activity}
emptyOptionLabel="Nenhuma atividade"
dialogDescription="Busque atividades do município selecionado por título."
emptyOptionLabel={CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL}
dialogDescription={CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION}
isQueryReady={activityQueryReady}
queryTooShortMessage="Selecione o município para buscar atividades."
search={searchActivitiesForMunicipality}
@ -148,8 +146,18 @@ export const DemandForm = ({
) : null}
</Field>
<Field>
<FieldLabel htmlFor="demand-description">Detalhe a necessidade</FieldLabel>
<Textarea id="demand-description" name="description" rows={4} maxLength={4000} />
<FieldLabel htmlFor="demand-description">{CAMPAIGN_DEMAND_BODY_LABEL}</FieldLabel>
<FieldDescription>{CAMPAIGN_DEMAND_BODY_DESCRIPTION}</FieldDescription>
<Textarea
id="demand-description"
name="description"
rows={5}
minLength={2}
maxLength={CAMPAIGN_DEMAND_BODY_MAX_LENGTH}
required
placeholder={CAMPAIGN_DEMAND_BODY_PLACEHOLDER}
className="min-h-28"
/>
{fieldError(state.fieldErrors, 'description') ? (
<FieldError>{fieldError(state.fieldErrors, 'description')}</FieldError>
) : null}
@ -158,7 +166,7 @@ export const DemandForm = ({
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
<Button type="submit" disabled={isPending} className="min-h-11 self-start">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Abrir demanda
{CAMPAIGN_DEMAND_SUBMIT_LABEL}
</Button>
</form>
)

View file

@ -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) => (
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={wizardNextStepTitle(actionSlug)}
isEntryStep={false}
previousHref={wizardActionHref(actionSlug, undefined, { returnPath })}
dismissHref={wizardReturnHref(returnPath)}
municipalityLabel={municipalityName}
>
<p className="text-sm text-muted-foreground">{wizardNextStepPlaceholder(actionSlug)}</p>
</CampaignWizardShell>
)

View file

@ -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 (
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
previousHref={wizardPreviousHref({
actionSlug,
stepKind: 'register-demand',
municipalitySlug,
returnPath,
})}
dismissHref={wizardReturnHref(returnPath)}
municipalityLabel={municipalityName}
contentFocus="none"
>
<form
action={submitAction}
className="flex flex-col gap-6"
aria-busy={isPending || undefined}
data-pending={isPending ? '' : undefined}
>
<input type="hidden" name="municipalityId" value={municipalityId} />
<Field>
<FieldLabel htmlFor="wizard-demand-kind">{CAMPAIGN_DEMAND_KIND_LABEL}</FieldLabel>
<NativeSelect
id="wizard-demand-kind"
name="kind"
defaultValue="material"
required
disabled={isPending}
className="min-h-11 w-full sm:w-56"
>
{campaignDemandKinds.map((kind) => (
<NativeSelectOption key={kind} value={kind}>
{campaignDemandKindLabels[kind]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel>{CAMPAIGN_DEMAND_ACTIVITY_LABEL}</FieldLabel>
<AsyncSearchCombobox
name="activityId"
label={CAMPAIGN_DEMAND_ACTIVITY_LABEL}
value={activity}
emptyOptionLabel={CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL}
dialogDescription={CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION}
search={searchActivities}
onChange={setActivity}
/>
<FieldDescription>{CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION}</FieldDescription>
{state.fieldErrors?.activity ? (
<FieldError>{state.fieldErrors.activity}</FieldError>
) : null}
</Field>
<Field>
<FieldLabel htmlFor="wizard-demand-description">{CAMPAIGN_DEMAND_BODY_LABEL}</FieldLabel>
<FieldDescription>{CAMPAIGN_DEMAND_BODY_DESCRIPTION}</FieldDescription>
<Textarea
id="wizard-demand-description"
name="description"
rows={5}
minLength={2}
maxLength={CAMPAIGN_DEMAND_BODY_MAX_LENGTH}
required
disabled={isPending}
placeholder={CAMPAIGN_DEMAND_BODY_PLACEHOLDER}
className="min-h-28"
/>
{state.fieldErrors?.description ? (
<FieldError>{state.fieldErrors.description}</FieldError>
) : null}
</Field>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="min-h-11 min-w-[8rem]">
{isPending ? (
<>
<Spinner data-icon="inline-start" aria-hidden="true" />
Salvando…
</>
) : (
CAMPAIGN_DEMAND_SUBMIT_LABEL
)}
</Button>
</div>
<div aria-live="polite" className="sr-only">
{isPending ? WIZARD_DEMAND_PENDING_ARIA : null}
</div>
</form>
</CampaignWizardShell>
)
}

View file

@ -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])
}

View file

@ -188,6 +188,7 @@ type WizardStepKind =
| 'update-body'
| 'leadership-grid'
| 'leadership-form'
| 'register-demand'
type WizardPreviousHrefInput = {
actionSlug: string

View file

@ -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

24
src/lib/demandTitle.ts Normal file
View file

@ -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()

View file

@ -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

View file

@ -24,6 +24,25 @@ export const campaignDemandKindLabels: Record<CampaignDemandKind, string> = {
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.'

View file

@ -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<typeof campaignDemandCreateSchema>
export type CampaignDemandUpdateInput = z.input<typeof campaignDemandUpdateSchema>
export type CampaignDemandTransitionInput = z.input<typeof campaignDemandTransitionSchema>
export type CampaignDemandCostInput = z.input<typeof campaignDemandCostSchema>

View file

@ -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<string | null> => {
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
}
}

View file

@ -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,
}
}

View file

@ -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)
})
})

View file

@ -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/)
})
})

View file

@ -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)
})
})