mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(B195): finalizar wizard Registrar pedido — campo único + título derivado por IA + edição no detalhe
This commit is contained in:
parent
f460dac6cf
commit
712d40f83f
26 changed files with 1076 additions and 123 deletions
|
|
@ -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).
|
||||
|
|
|
|||
35
docs/plans/escala-dry-pos-wizard-steps.md
Normal file
35
docs/plans/escala-dry-pos-wizard-steps.md
Normal 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).
|
||||
100
docs/plans/registrar-pedido-wizard-impl.md
Normal file
100
docs/plans/registrar-pedido-wizard-impl.md
Normal 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)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
26
src/app/(campaign)/campanha/(app)/acoes/formActions.ts
Normal file
26
src/app/(campaign)/campanha/(app)/acoes/formActions.ts
Normal 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.',
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
112
src/components/campaign/demand/DemandDescriptionEditor.tsx
Normal file
112
src/components/campaign/demand/DemandDescriptionEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
166
src/components/campaign/shared/WizardRegisterDemandStep.tsx
Normal file
166
src/components/campaign/shared/WizardRegisterDemandStep.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@ type WizardStepKind =
|
|||
| 'update-body'
|
||||
| 'leadership-grid'
|
||||
| 'leadership-form'
|
||||
| 'register-demand'
|
||||
|
||||
type WizardPreviousHrefInput = {
|
||||
actionSlug: string
|
||||
|
|
|
|||
|
|
@ -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
24
src/lib/demandTitle.ts
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
44
src/utilities/ai/campaignDemandTitle.ts
Normal file
44
src/utilities/ai/campaignDemandTitle.ts
Normal 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
|
||||
}
|
||||
}
|
||||
26
src/utilities/demand/campaignDemandFormData.ts
Normal file
26
src/utilities/demand/campaignDemandFormData.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
89
tests/e2e/campaignRegisterDemand.e2e.spec.ts
Normal file
89
tests/e2e/campaignRegisterDemand.e2e.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
52
tests/unit/demandTitle.unit.spec.ts
Normal file
52
tests/unit/demandTitle.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue