C8 F4b: widen formActions guard and migrate sibling ladders.

Cover *FormActions.ts (+ taskActions allowlist) so route siblings cannot drift past the shared wrappers; migrate activity/municipality ladders onto runCampaignFormAction.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 08:26:19 -03:00
parent fb2af739fc
commit 440c15f132
12 changed files with 215 additions and 233 deletions

File diff suppressed because one or more lines are too long

View file

@ -34,7 +34,7 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
| 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) |
| 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) |

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,17 @@ 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: `atividades/[slug]/taskActions.ts` (contrato posicional `Promise<{ok,message}>` para `useOptimistic`, não é ladder `(state, formData)`).
- Docstring de `runCampaignFormAction` precisada: a exceção de atividades é só `atividades/formActions.ts` (unique-violation + duplicate-title), não os irmãos.
**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,41 @@
'use server'
import { redirect } from 'next/navigation'
import { cancelActivity, markActivityRealized } from '@/app/(campaign)/campanha/actions/activity'
import { 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.',
}
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 +43,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,15 +132,24 @@ 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',
])
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(