mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge branch 'cursor/a1a74a92'
This commit is contained in:
commit
126b1abae8
12 changed files with 249 additions and 250 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue