Merge branch 'cursor/a1a74a92'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 08:35:23 -03:00
commit 126b1abae8
12 changed files with 249 additions and 250 deletions

File diff suppressed because one or more lines are too long

View file

@ -28,19 +28,19 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
## Smell Inventory
| Smell | Location | Refactoring | Status |
| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| God module: RBAC for every collection in one file (964 lines, ~80 exports) | `src/utilities/campaignAccess.ts` | split per domain under `src/utilities/access/` | done 2026-07-23 (14 domain modules; `campaignAccess.ts` kept as re-export surface) |
| God module: ~71 exports of electoral math | `src/lib/electionInsights.ts` | split by concern when next touched | done 2026-07-25 (Pass 2 W4a): module DELETED; survivors in `electionFormat.ts` + `voteTrend.ts` |
| Multi-feature action bag: CRUD + CSV import pipeline + LGPD in one file (722 lines) | `src/app/(campaign)/campanha/actions/supporter.ts` | split CRUD vs import | done 2026-07-23 (`supporterImport.ts`) |
| Clone twins: create/update/staff-assert/unique-conflict skeleton | `actions/organization.ts` ↔ `actions/stateDeputy.ts` | extract shared factory | done 2026-07-23 (`runStaffEntityMutation` in `campaignEntityActions.ts`) |
| 14 near-identical `formActions.ts` try/catch→mapError→redirect/return ladders | `src/app/(campaign)/campanha/**/formActions.ts` | shared form-action wrapper | partially done — C8 F4 (2026-07-21/23) unified the municipality staff wrappers + 4 `*/nova` ladders on `runCampaignRedirectFormAction`; **10 ladders written since remain hand-rolled** (844 lines: assessores, planos, apoiadores ×2, demandas/[slug], liderancas/[id], organizacoes/[slug], dobradinhas/[slug], municipios editar, convite) — done 2026-07-25 (Pass 2 W4d): `runCampaignFormAction` + 13 ações migradas; exceções documentadas em comentário (planos, apoiadores/[id], convite login) |
| Flat 80+-file `src/utilities/` junk drawer; `lib/` vs `utilities/` boundary undocumented | `src/utilities/`, `src/lib/` | document convention + move misfiled modules | done 2026-07-23 (convention in `.cursor/rules/engineering-standards.mdc`; `RefreshRouteOnSave` moved to `components/`) |
| Praça→Município rename leftovers in identifiers (`PLAZA_MAP_YEARS`, `ACCESSIBLE_PLAZA_IDS_CONTEXT_KEY`, …) | map/list utilities | finish rename | done 2026-07-23 |
| Props→state `useEffect` mirrors | `MunicipalityListExpectedVotesControl`, comboboxes | key-remount or fully controlled | closed 2026-07-23 (accepted as correct — see Debt Ledger row) |
| Detail-page RSCs mixing loading + tab UI + forms (300+ lines) | `municipios/[slug]/page.tsx`, `planos/[slug]/page.tsx` | extract tab loaders/sections | done 2026-07-25 (Pass 2 W4c): tabs em `MunicipalityDetailTabs.tsx` / `ActionPlanOverviewTab.tsx`; gêmeos de tab dedupados em `detailTabUi.ts` |
| God module: list URL + filters + sort + labels + freshness in one file (765 lines, 59 exports) | `src/utilities/municipalityUi.ts` | split along url/filters/sort/labels/freshness seams | done 2026-07-25 (Pass 2 W1-D1): `municipalityListUrl` / `municipalityListFilters` / `municipalityLabels` / `municipalitySignal` |
| `components/campaign/` flat with 106 files; `utilities/` flat with 92 | `src/components/campaign/`, `src/utilities/` | within-layer domain subfolders | components done 2026-07-25 (Pass 2 W2, 15 domínios); `utilities/` deferred with trigger: a domain gaining its 3rd new module in a month gets its subfolder |
| Smell | Location | Refactoring | Status |
| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| God module: RBAC for every collection in one file (964 lines, ~80 exports) | `src/utilities/campaignAccess.ts` | split per domain under `src/utilities/access/` | done 2026-07-23 (14 domain modules; `campaignAccess.ts` kept as re-export surface) |
| God module: ~71 exports of electoral math | `src/lib/electionInsights.ts` | split by concern when next touched | done 2026-07-25 (Pass 2 W4a): module DELETED; survivors in `electionFormat.ts` + `voteTrend.ts` |
| Multi-feature action bag: CRUD + CSV import pipeline + LGPD in one file (722 lines) | `src/app/(campaign)/campanha/actions/supporter.ts` | split CRUD vs import | done 2026-07-23 (`supporterImport.ts`) |
| Clone twins: create/update/staff-assert/unique-conflict skeleton | `actions/organization.ts` ↔ `actions/stateDeputy.ts` | extract shared factory | done 2026-07-23 (`runStaffEntityMutation` in `campaignEntityActions.ts`) |
| 14 near-identical `formActions.ts` try/catch→mapError→redirect/return ladders | `src/app/(campaign)/campanha/**/formActions.ts` | shared form-action wrapper | done 2026-07-25 (Pass 2 W4d): `runCampaignFormAction` + 13 ações migradas; exceções documentadas (atividades/formActions, apoiadores/[id], convite login). **C8 F4b ✓ 2026-07-27:** guard ampliado para `*FormActions.ts` + `taskActions.ts`; 6 irmãos migrados; `taskActions.ts` allowlisted — futuros siblings não driftam silenciosamente |
| Flat 80+-file `src/utilities/` junk drawer; `lib/` vs `utilities/` boundary undocumented | `src/utilities/`, `src/lib/` | document convention + move misfiled modules | done 2026-07-23 (convention in `.cursor/rules/engineering-standards.mdc`; `RefreshRouteOnSave` moved to `components/`) |
| Praça→Município rename leftovers in identifiers (`PLAZA_MAP_YEARS`, `ACCESSIBLE_PLAZA_IDS_CONTEXT_KEY`, …) | map/list utilities | finish rename | done 2026-07-23 |
| Props→state `useEffect` mirrors | `MunicipalityListExpectedVotesControl`, comboboxes | key-remount or fully controlled | closed 2026-07-23 (accepted as correct — see Debt Ledger row) |
| Detail-page RSCs mixing loading + tab UI + forms (300+ lines) | `municipios/[slug]/page.tsx`, `planos/[slug]/page.tsx` | extract tab loaders/sections | done 2026-07-25 (Pass 2 W4c): tabs em `MunicipalityDetailTabs.tsx` / `ActionPlanOverviewTab.tsx`; gêmeos de tab dedupados em `detailTabUi.ts` |
| God module: list URL + filters + sort + labels + freshness in one file (765 lines, 59 exports) | `src/utilities/municipalityUi.ts` | split along url/filters/sort/labels/freshness seams | done 2026-07-25 (Pass 2 W1-D1): `municipalityListUrl` / `municipalityListFilters` / `municipalityLabels` / `municipalitySignal` |
| `components/campaign/` flat with 106 files; `utilities/` flat with 92 | `src/components/campaign/`, `src/utilities/` | within-layer domain subfolders | components done 2026-07-25 (Pass 2 W2, 15 domínios); `utilities/` deferred with trigger: a domain gaining its 3rd new module in a month gets its subfolder |
## Sprout / Wrap Register

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-C6 (apoiadores / import / listas)
Status: F4 (DRY de forms) **entregue** — twin praça 2026-07-21 + hardening 2026-07-23; **o resto do F4 (10 escadas hand-rolled escritas depois) fechou em 2026-07-25 no Pass 2 W4d** (`runCampaignFormAction`, exceções documentadas em comentário); **F3 (parsers das listas de entidade) fechou no Pass 2 W1-D2**; **F4b aberta** (o guard do F4 só inspeciona `formActions.ts`, deixando os arquivos de action irmãos sem verificação — achado do `/simplify` do C13); F1–F2 abertos com gatilho (volume real de import/base nominal pós-Onda 0)
Atualizado em: 2026-07-25 (F4b registrada pelo `capture-review-debts` pós-C13)
Status: F4 (DRY de forms) **entregue** — twin praça 2026-07-21 + hardening 2026-07-23; **o resto do F4 (10 escadas hand-rolled escritas depois) fechou em 2026-07-25 no Pass 2 W4d** (`runCampaignFormAction`, exceções documentadas em comentário); **F3 (parsers das listas de entidade) fechou no Pass 2 W1-D2**; **F4b entregue 2026-07-27** (guard ampliado para `*FormActions.ts` + `taskActions.ts`; 6 irmãos migrados; `taskActions.ts` allowlisted); F1–F2 abertos com gatilho (volume real de import/base nominal pós-Onda 0)
Atualizado em: 2026-07-27 (F4b entregue — guard + migração dos irmãos)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, item C8)
Responsável: —
@ -88,13 +88,27 @@ flowchart TD
- ~~Migrar `fieldError`/`firstError` local em forms legados~~ — `CampaignInviteForm.tsx` já importa de `campaignFormFields.ts`.
- **Nota pós-remodel:** paths `nucleos/*`/`pracas/*` foram superseded; não reabrir.
### Fase 4b — o guard do F4 só vê `formActions.ts` — **ABERTA** (registrada 2026-07-25 pós-C13)
### Fase 4b — o guard do F4 só vê `formActions.ts` — **ENTREGUE** (2026-07-27)
Appetite: **~2h eng, fill-in.** O `/simplify` do C13 mostrou que a invariante do F4 é menor do que o texto acima sugere: o guard `campaign formActions convention` (`tests/unit/codebaseConventions.unit.spec.ts`) filtra `file.endsWith('/formActions.ts')`, então os arquivos de action irmãos que cada rota escreveu depois — `lifecycleFormActions.ts`, `resultFormActions.ts`, `updateFormActions.ts`, `taskActions.ts` — **não são verificados em nenhuma rota**. A exceção documentada na Revisão 2026-07-24 ("`lifecycleFormActions.ts` só retorna safe-messages fixas — sem ladder para migrar") foi escrita quando o alvo era `mapCampaignFormActionError`; com `runCampaignRedirectFormAction` (Pass 2 W4d) a conta mudou, porque esse arquivo faz exatamente a forma que o wrapper padroniza: dois `catch` nus que descartam o erro + `redirect` no sucesso.
Appetite: **~2h eng, fill-in.** O `/simplify` do C13 mostrou que a invariante do F4 é menor do que o texto acima sugere: o guard `campaign formActions convention` (`tests/unit/codebaseConventions.unit.spec.ts`) filtrava `file.endsWith('/formActions.ts')`, então os arquivos de action irmãos que cada rota escreveu depois — `lifecycleFormActions.ts`, `resultFormActions.ts`, `updateFormActions.ts`, `taskActions.ts` — **não eram verificados em nenhuma rota**. A exceção documentada na Revisão 2026-07-24 ("`lifecycleFormActions.ts` só retorna safe-messages fixas — sem ladder para migrar") foi escrita quando o alvo era `mapCampaignFormActionError`; com `runCampaignRedirectFormAction` (Pass 2 W4d) a conta mudou, porque esse arquivo faz exatamente a forma que o wrapper padroniza: dois `catch` nus que descartam o erro + `redirect` no sucesso.
- Ampliar o filtro do guard para os irmãos (`/^\w*[Ff]ormActions\.ts$/` + `taskActions.ts`, ou um sufixo declarado) e allowlistar com racional escrito o que realmente não couber.
- Migrar o que o wrapper cobre; onde o `catch` nu ficar, decidir se o erro deve ser observável (hoje é descartado sem log em ambos os caminhos, então não é um débito diferencial — verificar no PR antes de inflar o escopo).
- Não escopo: mudar a semântica das safe-messages (copy revisada) e tocar `actions/*.ts` (as server actions em si já usam transação/lock).
**As-built:**
- Guard ampliado para basename `/^\w*[Ff]ormActions\.ts$/` + `taskActions.ts`.
- Migrados para `runCampaignRedirectFormAction` / `runCampaignFormAction`: `atividades/[slug]/lifecycleFormActions.ts`, `resultFormActions.ts`, `updateFormActions.ts`; `municipios/[slug]/updateFormActions.ts`, `pledgeFormActions.ts`; `municipios/municipalityStaffFormActions.ts` (4 actions).
- Allowlist com racional: `taskActions.ts` (contrato posicional); `atividades/formActions.ts` (unique-violation + duplicate-title); `apoiadores/[id]/formActions.ts` (message-only flatten).
- Guard exige call site `runCampaign…FormAction(` — comentário nomeando o wrapper não passa.
- Docstring de `runCampaignFormAction` precisada: as exceções hand-rolled são só os dois allowlisted acima.
- `/simplify` pós-F4b: pre-parse de `id` em `lifecycleFormActions` só engole `FormDataBoundaryError`.
**Já resolvido no simplify (não reabrir):** loophole de comment-token no guard; `catch` nu do lifecycle id.
**Explicitamente fora / Adiado com gatilho (triage 2026-07-27):**
- `actions/password.ts` / `profile.ts` / `leaderSupporter.ts` exportam `*FormAction` com ladder hand-rolled fora do filtro `*FormActions.ts` — gatilho: próximo toque nesses arquivos **ou** decisão de expandir o guard além de route-level FormActions.
- `convite/[token]/formActions.ts` login half ainda hand-rolled (password-confirm + redirect) — gatilho: próximo toque no convite; autofill já usa o wrapper.
**Revisão 2026-07-27:** F4b fechada; F1–F2 seguem com gatilho de volume.
**Migration:** Fases 1, 3, 4, 4b sem schema. Fase 2: `pnpm migrate:create add_contact_trgm_index` (extensão + 2 GIN indexes). Sem Consent novo.

File diff suppressed because one or more lines are too long

View file

@ -1,44 +1,42 @@
'use server'
import { redirect } from 'next/navigation'
import { cancelActivity, markActivityRealized } from '@/app/(campaign)/campanha/actions/activity'
import { requiredRelationshipFormValue } from '@/lib/formData'
import { FormDataBoundaryError, requiredRelationshipFormValue } from '@/lib/formData'
import type { Activity } from '@/payload-types'
import {
runCampaignRedirectFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
export type ActivityLifecycleFormState = {
message?: string
}
const ACTIVITY_ID_MISSING_MESSAGE =
'Não foi possível identificar a atividade. Atualize a página e tente novamente.'
// Parse the id before the redirect ladder so a missing/invalid hidden field
// returns `message` (ActivityLifecycleDialog only reads that — not fieldErrors).
const setActivityLifecycleFormAction = async (
formData: FormData,
mutate: (activityId: number) => Promise<Pick<Activity, 'slug'>>,
failureMessage: string,
): Promise<ActivityLifecycleFormState> => {
): Promise<CampaignFormActionState> => {
let activityId: number
try {
activityId = requiredRelationshipFormValue(formData, 'id')
} catch {
return {
message: 'Não foi possível identificar a atividade. Atualize a página e tente novamente.',
}
} catch (error) {
if (!(error instanceof FormDataBoundaryError)) throw error
return { message: ACTIVITY_ID_MISSING_MESSAGE }
}
let activitySlug: string
try {
const activity = await mutate(activityId)
activitySlug = activity.slug
} catch {
return { message: failureMessage }
}
redirect(`/campanha/atividades/${activitySlug}`)
return runCampaignRedirectFormAction({
execute: () => mutate(activityId),
redirectTo: (activity) => `/campanha/atividades/${activity.slug}`,
genericMessage: failureMessage,
})
}
export const cancelActivityFormAction = async (
_state: ActivityLifecycleFormState,
_state: CampaignFormActionState,
formData: FormData,
): Promise<ActivityLifecycleFormState> =>
): Promise<CampaignFormActionState> =>
setActivityLifecycleFormAction(
formData,
cancelActivity,
@ -46,9 +44,9 @@ export const cancelActivityFormAction = async (
)
export const markActivityRealizedFormAction = async (
_state: ActivityLifecycleFormState,
_state: CampaignFormActionState,
formData: FormData,
): Promise<ActivityLifecycleFormState> =>
): Promise<CampaignFormActionState> =>
setActivityLifecycleFormAction(
formData,
markActivityRealized,

View file

@ -5,7 +5,7 @@ import { revalidatePath } from 'next/cache'
import { registerActivityResultAction } from '@/app/(campaign)/campanha/actions/activity'
import { requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import {
mapCampaignFormActionError,
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
@ -21,20 +21,17 @@ const safeMessages = [
export const registerActivityResultFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const activityId = requiredRelationshipFormValue(formData, 'activityId')
const resultSummary = requiredFormText(formData, 'resultSummary')
// Media upload is a follow-up: the v1 form submits text only.
await registerActivityResultAction(activityId, resultSummary, [])
revalidatePath('/campanha/atividades/[slug]', 'page')
return { status: 'success', message: 'Resultado registrado com sucesso.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages,
genericMessage:
'Não foi possível registrar o resultado. Verifique seu acesso e tente novamente.',
})
}
}
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const activityId = requiredRelationshipFormValue(formData, 'activityId')
const resultSummary = requiredFormText(formData, 'resultSummary')
// Media upload is a follow-up: the v1 form submits text only.
await registerActivityResultAction(activityId, resultSummary, [])
revalidatePath('/campanha/atividades/[slug]', 'page')
return { message: 'Resultado registrado com sucesso.' }
},
safeMessages,
genericMessage:
'Não foi possível registrar o resultado. Verifique seu acesso e tente novamente.',
})

View file

@ -4,13 +4,10 @@ import { revalidatePath } from 'next/cache'
import { appendActivityUpdate } from '@/app/(campaign)/campanha/actions/activity'
import { requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
export type ActivityUpdateFormState = {
status?: 'success'
message?: string
fieldErrors?: Record<string, string[]>
}
import {
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
// The specific validation/transaction messages `appendActivityUpdate` throws —
// safe to surface verbatim. Anything else (e.g. an unexpected Payload API error)
@ -22,21 +19,18 @@ const safeMessages = [
] as const
export const createActivityUpdateFormAction = async (
_state: ActivityUpdateFormState,
_state: CampaignFormActionState,
formData: FormData,
): Promise<ActivityUpdateFormState> => {
try {
const activityId = requiredRelationshipFormValue(formData, 'activityId')
const body = requiredFormText(formData, 'body')
await appendActivityUpdate(activityId, body)
revalidatePath('/campanha/atividades/[slug]', 'page')
return { status: 'success', message: 'Atualização registrada com sucesso.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages,
genericMessage:
'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
})
}
}
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const activityId = requiredRelationshipFormValue(formData, 'activityId')
const body = requiredFormText(formData, 'body')
await appendActivityUpdate(activityId, body)
revalidatePath('/campanha/atividades/[slug]', 'page')
return { message: 'Atualização registrada com sucesso.' }
},
safeMessages,
genericMessage:
'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
})

View file

@ -10,7 +10,7 @@ import {
voteEstimateScenarioFromForm,
} from '@/lib/formData'
import {
mapCampaignFormActionError,
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
@ -23,50 +23,44 @@ const declareSafeMessages = [
export const declareVotesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const leadership = requiredRelationshipFormValue(formData, 'leadershipId')
const declaredVotes = requiredIntegerFormValue(formData, 'declaredVotes', {
minimum: 0,
maximum: 1_000_000,
})
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const leadership = requiredRelationshipFormValue(formData, 'leadershipId')
const declaredVotes = requiredIntegerFormValue(formData, 'declaredVotes', {
minimum: 0,
maximum: 1_000_000,
})
await declareVotes({ municipality, leadership, declaredVotes })
revalidatePath('/campanha/municipios/[slug]', 'page')
return { status: 'success', message: 'Declaração de votos registrada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: declareSafeMessages,
genericMessage:
'Não foi possível registrar a declaração. Verifique seu acesso e tente novamente.',
})
}
}
await declareVotes({ municipality, leadership, declaredVotes })
revalidatePath('/campanha/municipios/[slug]', 'page')
return { message: 'Declaração de votos registrada.' }
},
safeMessages: declareSafeMessages,
genericMessage:
'Não foi possível registrar a declaração. Verifique seu acesso e tente novamente.',
})
export const estimateVotesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const pledge = requiredRelationshipFormValue(formData, 'pledgeId')
const estimatedVotes = voteEstimateScenarioFromForm(formData, 'estimatedVotes')
const estimateNote = nullableFormText(formData, 'estimateNote')
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const pledge = requiredRelationshipFormValue(formData, 'pledgeId')
const estimatedVotes = voteEstimateScenarioFromForm(formData, 'estimatedVotes')
const estimateNote = nullableFormText(formData, 'estimateNote')
await estimateVotes({
pledge,
estimatedVotes,
estimateNote,
})
revalidatePath('/campanha/municipios/[slug]', 'page')
return { status: 'success', message: 'Estimativa registrada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente a coordenação e a assessoria registram estimativas.'],
genericMessage:
'Não foi possível registrar a estimativa. Verifique seu acesso e tente novamente.',
})
}
}
await estimateVotes({
pledge,
estimatedVotes,
estimateNote,
})
revalidatePath('/campanha/municipios/[slug]', 'page')
return { message: 'Estimativa registrada.' }
},
safeMessages: ['Somente a coordenação e a assessoria registram estimativas.'],
genericMessage:
'Não foi possível registrar a estimativa. Verifique seu acesso e tente novamente.',
})

View file

@ -16,41 +16,38 @@ import {
type MunicipalityUpdateKind,
} from '@/lib/schemas/municipalityUpdate'
import {
mapCampaignFormActionError,
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
export const createMunicipalityUpdateFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const rawKind = requiredFormText(formData, 'kind')
const kind = municipalityUpdateKinds.includes(rawKind as MunicipalityUpdateKind)
? (rawKind as MunicipalityUpdateKind)
: 'semanal'
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const rawKind = requiredFormText(formData, 'kind')
const kind = municipalityUpdateKinds.includes(rawKind as MunicipalityUpdateKind)
? (rawKind as MunicipalityUpdateKind)
: 'semanal'
await createMunicipalityUpdate({
municipality,
kind,
worked: optionalFormText(formData, 'worked'),
failed: optionalFormText(formData, 'failed'),
needs: optionalFormText(formData, 'needs'),
body: optionalFormText(formData, 'body'),
activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'),
newSupports: optionalIntegerFormValue(formData, 'newSupports'),
signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
signalSource: optionalFormText(formData, 'signalSource'),
triangulated: checkboxFormValue(formData, 'triangulated'),
})
revalidatePath('/campanha/municipios/[slug]', 'page')
return { status: 'success', message: 'Atualização registrada com sucesso.' }
} catch (error) {
return mapCampaignFormActionError({
error,
genericMessage:
'Não foi possível registrar a atualização. Verifique seu acesso e tente novamente.',
})
}
}
await createMunicipalityUpdate({
municipality,
kind,
worked: optionalFormText(formData, 'worked'),
failed: optionalFormText(formData, 'failed'),
needs: optionalFormText(formData, 'needs'),
body: optionalFormText(formData, 'body'),
activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'),
newSupports: optionalIntegerFormValue(formData, 'newSupports'),
signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
signalSource: optionalFormText(formData, 'signalSource'),
triangulated: checkboxFormValue(formData, 'triangulated'),
})
revalidatePath('/campanha/municipios/[slug]', 'page')
return { message: 'Atualização registrada com sucesso.' }
},
genericMessage:
'Não foi possível registrar a atualização. Verifique seu acesso e tente novamente.',
})

View file

@ -21,7 +21,7 @@ import {
} from '@/lib/schemas/municipality'
import { parseMunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import {
mapCampaignFormActionError,
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import { revalidateMunicipalityListPaths } from '@/utilities/municipalityRevalidation'
@ -31,91 +31,79 @@ import { municipalityStaffEditSafeMessages } from './municipalityStaffEditMessag
export const setMunicipalityExpectedVotesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const expectedVotes = voteEstimateScenarioFromForm(formData, 'expectedVotes')
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const expectedVotes = voteEstimateScenarioFromForm(formData, 'expectedVotes')
await setMunicipalityExpectedVotes({ municipality, expectedVotes })
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { status: 'success', message: 'Votos estimados atualizados.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: municipalityStaffEditSafeMessages,
genericMessage:
'Não foi possível salvar os votos estimados. Verifique seu acesso e tente novamente.',
})
}
}
await setMunicipalityExpectedVotes({ municipality, expectedVotes })
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { message: 'Votos estimados atualizados.' }
},
safeMessages: municipalityStaffEditSafeMessages,
genericMessage:
'Não foi possível salvar os votos estimados. Verifique seu acesso e tente novamente.',
})
export const setMunicipalityPoliticalTrendFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const status = parsePoliticalTrendStatusFormValue(optionalFormText(formData, 'trendStatus'))
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const status = parsePoliticalTrendStatusFormValue(optionalFormText(formData, 'trendStatus'))
await setMunicipalityPoliticalTrend({
municipality,
status,
note: nullableFormText(formData, 'trendNote'),
})
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { status: 'success', message: 'Tendência política registrada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: municipalityStaffEditSafeMessages,
genericMessage:
'Não foi possível registrar a tendência. Verifique seu acesso e tente novamente.',
})
}
}
await setMunicipalityPoliticalTrend({
municipality,
status,
note: nullableFormText(formData, 'trendNote'),
})
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { message: 'Tendência política registrada.' }
},
safeMessages: municipalityStaffEditSafeMessages,
genericMessage:
'Não foi possível registrar a tendência. Verifique seu acesso e tente novamente.',
})
export const assignMunicipalityAdvisorsFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const advisors = repeatedRelationshipFormValues(formData, 'advisors')
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const advisors = repeatedRelationshipFormValues(formData, 'advisors')
await assignMunicipalityAdvisors({ municipality, advisors })
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { status: 'success', message: 'Assessores atualizados.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: [MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE],
genericMessage:
'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
})
}
}
await assignMunicipalityAdvisors({ municipality, advisors })
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { message: 'Assessores atualizados.' }
},
safeMessages: [MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE],
genericMessage:
'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
})
export const createMunicipalityListSignalFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
await createMunicipalityUpdate({
municipality,
kind: 'sinal',
body: optionalFormText(formData, 'body'),
signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
signalSource: optionalFormText(formData, 'signalSource'),
triangulated: checkboxFormValue(formData, 'triangulated'),
})
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { status: 'success', message: 'Sinal registrado.' }
} catch (error) {
return mapCampaignFormActionError({
error,
genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.',
})
}
}
await createMunicipalityUpdate({
municipality,
kind: 'sinal',
body: optionalFormText(formData, 'body'),
signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
signalSource: optionalFormText(formData, 'signalSource'),
triangulated: checkboxFormValue(formData, 'triangulated'),
})
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { message: 'Sinal registrado.' }
},
genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.',
})

View file

@ -57,10 +57,10 @@ export const mapCampaignFormActionError = <Values>(
* success state (`message` plus extras like created ids). Pre-parsed `values`
* and `revision` are echoed on failure so forms can repopulate.
*
* Documented exceptions that deliberately stay hand-rolled: the atividades
* ladders (custom unique-violation mapping + async duplicate-title fallback)
* and apoiadores/[id] (flattens field errors into message-only states for
* its inline controls).
* Documented exceptions that deliberately stay hand-rolled:
* `atividades/formActions.ts` (custom unique-violation mapping + async
* duplicate-title fallback) and `apoiadores/[id]/formActions.ts` (flattens
* field errors into message-only states for its inline controls).
*/
export const runCampaignFormAction = async <
Success extends { message: string },

View file

@ -1,5 +1,5 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { basename, relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
@ -132,21 +132,37 @@ describe('eslint legacy ignore lists', () => {
})
describe('campaign formActions convention', () => {
// Every route-level formActions.ts goes through the shared wrappers
// (runCampaignFormAction / runCampaignRedirectFormAction) so error mapping
// and revalidation cannot drift per route (Pass 2 W4d). A file that truly
// cannot use them gets an allowlist entry documenting why:
const allowlist = new Set<string>([])
// Every route-level *FormActions.ts (and the known sibling taskActions.ts)
// goes through the shared wrappers (runCampaignFormAction /
// runCampaignRedirectFormAction) so error mapping and revalidation cannot
// drift per route (Pass 2 W4d / C8 F4b). A file that truly cannot use them
// gets an allowlist entry documenting why:
const isGuardedActionFile = (name: string): boolean =>
/^\w*[Ff]ormActions\.ts$/.test(name) || name === 'taskActions.ts'
it('routes every formActions.ts through a shared wrapper', () => {
const allowlist = new Set<string>([
// Positional (activityId, taskId, done) => Promise<{ok, message}> consumed
// by useOptimistic in ActivityTaskChecklist — not a (state, formData) ladder,
// nothing to route through the shared wrapper.
'src/app/(campaign)/campanha/(app)/atividades/[slug]/taskActions.ts',
// Custom unique-violation → fieldErrors + async duplicate-title fallback
// that links to the existing activity — policy the wrappers don't grow for.
'src/app/(campaign)/campanha/(app)/atividades/formActions.ts',
// Flattens field errors into message-only states for inline detail controls.
'src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts',
])
it('routes every *FormActions / taskActions ladder through a shared wrapper', () => {
const offenders = walkSourceFiles(resolve(repoRoot, 'src/app/(campaign)'), ['.ts'])
.filter((file) => file.endsWith('/formActions.ts'))
.filter((file) => isGuardedActionFile(basename(file)))
.map(repoPath)
.filter((path) => !allowlist.has(path))
.filter(
(path) =>
!/runCampaign(Redirect)?FormAction/.test(readFileSync(resolve(repoRoot, path), 'utf8')),
)
.filter((path) => {
// Require a real call site — a comment naming the wrappers must not pass
// (the documented exceptions above used to hide behind that loophole).
const source = readFileSync(resolve(repoRoot, path), 'utf8')
return !/\brunCampaign(Redirect)?FormAction\s*\(/.test(source)
})
expect(offenders, 'wrap with runCampaignFormAction/runCampaignRedirectFormAction').toEqual([])
})