diff --git a/docs/GUARDRAILS.md b/docs/GUARDRAILS.md index c0d5cb95..5a13d902 100644 --- a/docs/GUARDRAILS.md +++ b/docs/GUARDRAILS.md @@ -26,7 +26,7 @@ Ledger dos guardrails determinísticos colhidos de `kind:agent-miss` (fluxo `pnp | ------------------------------------------------------------------------ | ------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------- | | Vocabulário banido (Praça/Núcleo/Plano de Ação, accent-tolerant) | C13 / W4f / P3-K | 3 | `codebaseConventions.unit.spec.ts` | | `server-only` em utilities acopladas (sweep payload/next/\*) | Pass 2 W2 / P3-K | 3 | `codebaseConventions.unit.spec.ts` | -| Escadas de formActions nos wrappers (`runCampaignFormAction`/`Redirect`) | W4d / C8 F4b | 3 | `codebaseConventions.unit.spec.ts` (filename `*FormActions.ts` — dodge `actions/*.ts` ledgerado) | +| Escadas de formActions nos wrappers (`runCampaignFormAction`/`Redirect`) | W4d / C8 F4b / OPS14 | 3 | `codebaseConventions.unit.spec.ts` (sweep `mapCampaignFormActionError` em `(campaign)` — não filename) | | Ratchet de bypass admin não documentado | P3-E | 3 | `codebaseConventions.unit.spec.ts` (por arquivo, só diminui) | | Mensagens de recusa como constantes + correspondência throw↔safelist | P3-A | 3 | `codebaseConventions.unit.spec.ts:216-263` | | `as never` banido | Pass 1 | 2 | ESLint `no-restricted-syntax` | diff --git a/docs/TECH-DEBT.md b/docs/TECH-DEBT.md index e2ca4de3..918d6923 100644 --- a/docs/TECH-DEBT.md +++ b/docs/TECH-DEBT.md @@ -42,7 +42,7 @@ Ledger of known engineering debt. Items large enough to be scheduled work become | Scripts re-spell the CLI skeleton: `die` ×7, `sha256` ×5, download→cache→sha256 ×5 (~20 lines each), `writeJson/writeText` ×5 (the newest script copied the OLD form — live drift), `cacheDir` ×5, dotenv preamble ×6; **two municipality-name resolvers diverged** — the supporter/CSV path lacks the TSE alias table and rejects 4 of 5 curated spellings ("Camacã", "QUINJINGUE", "Santa Terezinha", "Muquém do São Francisco"); `LOCAL_HOSTS` ×2 diverged; one-shot `generate-remodel-municipalities-migration.mjs` left behind holding 2 allowlist entries | `scripts/seed-tse-results.mjs`, `build-bahia-geometries.mjs`, `build-municipality-zone-geometries.mjs`, `build-bahia-demographics.mjs`, `build-bahia-tse-city-codes.mjs`, `seed-posts.mjs`, `db-pull.mjs`; `lib/schemas/supporter.ts:19-28` vs `lib/electionResults.ts:120-184` | duplication + latent import bug | medium | M | P2 | closed 2026-07-29 (P3-H): `scripts/lib/cli.mjs` (`dieWithLabel`/`sha256Hex`/`loadCliEnv`/`ensureCachedDownload`/`writeRepoFile`/`LOCAL_HOSTS`/`isRemoteDbOverrideSet`) adopted by 10 scripts, artifacts proven byte-identical (only `accessed` dates moved, reverted); **latent bug fixed**: single fold `municipalityNameAliases.ts` — supporter import now accepts all 5 curated spellings, pinned for BOTH paths in `municipalityNameAliases.unit.spec.ts`; db-pull gained the `postgres` host (deliberate); one-shot deleted + 2 allowlist entries freed; CLI-skeleton ban guard (die/sha256/dotenv/LOCAL_HOSTS) | P3-H | | **`utilities/` subfoldering trigger (Pass 2 D1) has FIRED:** 122 top-level modules (was 92); new in 30 days (`git --diff-filter=A`): municipality +21, supporter +10, territory +5, leadership +5, webauthn +5, visit +4 — 6 domains past "3rd new module in a month". Pure modules stranded in the layer: `campaignTime.ts` (140 lines, zero imports), `relationship.ts` (33 lines, zero imports) → `lib/` | `src/utilities/` | organization | low (path churn) | M | P2 | closed 2026-07-29 (P3-J): 41 modules moved — `municipality/` (incl. `campaignMunicipalityScope`, `goalCoverage`), `supporter/`, `territory/` (+`loadTerritoryOverview`), `leadership/`, `visit/`, `webauthn/`; direct imports, no barrels; `campaignTime` + `relationship` → `lib/`; top-level of `src/utilities/` pinned in `codebaseConventions` (D1 trigger encoded — new top-level module fails the build unregistered); `codebase-map.mdc` + `ARCHITECTURE.md` updated in the same delivery | P3-J | | `createdBy`/actor-stamp field duplicated verbatim across 5–7 collections + stamping hook ×3 (organic drift already: Supporter/Leadership grew `read:`, CampaignInvite is `required`); a field factory precedent exists (`voteEstimateScenarioFields`); `trimmedText` ×4 byte-identical in collection configs (pure helper outside `lib/`) | `CampaignDemand.ts:382-394`, `Organization.ts:121-133`, `StateDeputy.ts:105-117`, `AllocationDecision.ts:140-151`, `VotePledge.ts:211-222`, `Supporter.ts:214-227`, `Leadership.ts:222-234`; hooks `Organization.ts:54-62`, `StateDeputy.ts:54-62`, `Leadership.ts:63-69` | duplication | low | S | P3 | closed 2026-07-29 (P3-I): `campaignAuditFields.ts` (`systemStampedActorField` with `required`/`indexed`/`readAccess` data + `stampCampaignCreatedBy`) adopted by 9 collections — zero schema delta (`declaredBy` keeps `indexed: false`, no migration); `trimmedText` → `lib/text.ts` ×4; guard bans hand-spelled actor fields | P3-I | -| Route layer: W4d filename guard escapes — 7 hand-rolled form-action ladders in `actions/*.ts` invisible to the `*FormActions.ts` guard (3 expressible today with zero delta: profile avatar ×2, leaderSupporter); **convite-login exception stale** (not allowlisted; now expressible via `runCampaignRedirectFormAction`); `password.ts`/`auth.ts` genuinely bespoke but unregistered; staff-page prologue ×22 (~110 lines) with `return null` vs `redirect` divergence ×5; route body schema re-spelled instead of omit/extend (`municipios/advisors/route.ts:16-20`); `requireConsentByKey` called raw where the named wrapper exists (`leaderSupporter.ts:99-104`) | `actions/profile.ts:37-105,107-151`, `actions/leaderSupporter.ts:99-104,151-190`, `campanha/convite/[token]/formActions.ts:68-98`, 22 `(app)` pages | duplication + convention breach | low-medium | M | P3 | partially closed 2026-07-29 (P3-I): `requireCampaignPageActor({gate, redirectTo})` adopted by all 30 `(app)` pages — the `return null` ×5 divergence died (missing session now always redirects to login); guard bans `getCampaignUser()` in route pages. **Remaining open (defer + trigger: the next bespoke ladder or route schema):** 7 hand-rolled `actions/*.ts` ladders (3 expressible: profile avatar ×2, leaderSupporter), stale convite-login exception, password.ts/auth.ts registration in the W4d guard widening, `municipios/advisors/route.ts` omit/extend. **Verified 2026-07-31 (Pass 4):** new actions (notifications/webauthn/suggestion/wizardLeadership) DO use the shared wrappers — count holds at 7; the raw `requireConsentByKey` call is gone (leaderSupporter uses `requireSupporterRegistrationConsent`); 33/34 pages on `requireCampaignPageActor` + 1 redirect (`assessores/novo`, guard covers only `getCampaignUser`) | P3-I | +| Route layer: W4d form-action guard + staff-page/route-schema leftovers — filename guard escaped `actions/*.ts` ladders (closed OPS14 #241); `password.ts`/`auth.ts` bespoke but now allowlisted; route body schema re-spelled instead of omit/extend (`municipios/advisors/route.ts:16-20`) | `actions/auth.ts`, `actions/password.ts`, `municipios/advisors/route.ts:16-20` | duplication + convention breach | low | S–M | P3 | partially closed 2026-08-02 (OPS14 #241 / P3-I): guard sweeps `mapCampaignFormActionError` across `(campaign)` (not filename); profile avatar ×2, leaderSupporter, convite-login migrated to wrappers; auth/password allowlisted with reason. **Remaining open (defer + trigger: next bespoke ladder or route schema):** `municipios/advisors/route.ts` omit/extend | P3-I | | Convention guards dodgeable: banned-vocabulary regex `/praç|núcleo/i` misses unaccented `praca`/`nucleo` (the sibling "Plano de Ação" row was hardened accent-tolerant); `server-only` guard sweeps only `payload\|@payload-config` value imports — `next/cache`/`next/server` importers (`municipalityRevalidation.ts`, `campaignJsonMutationRoute.ts`) are server-bound and invisible; no mechanism stops a client component value-importing the three client-safe modules that drag the 623 KB TSE artifact (`municipalityTerritorialClass`, `municipalityPotential`, `territoryIntraCaptureBenchmark` — B13 avoided by discipline) | `tests/unit/codebaseConventions.unit.spec.ts:50,71-80` | tooling gap | low | S | P3 | closed 2026-07-29 (P3-K): vocabulary regex now `/pra[çc]\|n[úu]cleo/i` (2 genuine-data hits allowlisted); `server-only` sweep widened to `next/cache`/`next/server`/`next/headers` (`campaignJsonMutationRoute` marked); ESLint `no-restricted-imports` bars value-imports of the 3 artifact carriers + the artifact itself from `src/components/**` (type imports safe — measured: the 2 current consumers are type-only) | P3-K | | Constants/types single-sourcing cluster: territorial-class union declared twice across the lib/utilities boundary (adding a class compiles green and mis-sorts); `DAY_MS` ×3 (one exported from `suggestionCatalog`'s public interface); `1_000_000` vote-count ceiling ×6 across two validation layers; password `8/128` + token `20/256` policies ×2; TSE baseline years mirrored (documented excuse dies with a leaf module); `formatBrazilianPhoneDisplay` near-twin (1 call site); `advisorMunicipalitiesBatchSchema` missing the dedup transform its 3 siblings have; `voteEstimate` formatters re-spell the pt-BR format without the E18 zero-fraction pin; `hasAnyEstimate` weaker twin of `hasAnyVoteEstimate` | `lib/territorialClassSortWeight.ts:10-13`, `lib/suggestionCatalog.ts:15-19,175`, `lib/engagementLevel.ts:97`, `utilities/municipalitySignal.ts:32`, `lib/formData.ts:123-133`, `lib/schemas/votePledge.ts:9,32`, `lib/schemas/invite.ts:11,39`, `lib/voteEstimate.ts:27,102-126`, `lib/schemas/advisor.ts:69-72` | shotgun surgery | low-medium | S each | P3 | closed 2026-07-29 (P3-K): territorial-class union → `lib/territorialClassAnchors.ts` (type error on new class now); `DAY_MS` → `lib/text.ts` (out of `suggestionCatalog`'s public interface); `MAX_VOTE_COUNT` in `lib/schemas/primitives.ts` ×6 folded; `campaignPasswordSchema` exported (invite delegates); `lib/electionYears.ts` leaf (mirror + documented excuse died); `formatBrazilianPhoneDisplay` delegates; batch-schema dedup transform (action's `[...new Set]` died); `formatElectionNumber` in the 3 `voteEstimate` formatters; `hasAnyEstimate` deleted | P3-K | | Form-action feedback JSX at 15+ call sites with `aria-live` on only 5 — **10 error alerts silent to AT**; success→toast effect ×7; borderless inline-input class ×4 verbatim; deprecated `showLabels` prop with zero call sites (knip can't see props) | 15 forms incl. `LeadershipForm.tsx:162`, `DemandForm.tsx:152`, `MunicipalityUpdateForm.tsx:168`, `PledgeEstimateForm.tsx:74`; `VoteEstimateScenarioStrip.tsx:18,27,32` | a11y + duplication | medium (a11y) | S–M | P3 | closed 2026-07-29 (P3-G): `CampaignFormActionMessage` (aria-live on both variants + `formFieldErrors`) adopted by all 15+ sites — every alert now announces; `useCampaignFormSuccessToast` adopted ×7; `campaignInlineInputClassName` ×4; `showLabels` deleted; JSX ban guard + a11y pin (`campaignFormActionMessage.unit.spec.tsx`, unit include extended to `.tsx`) | P3-G | diff --git a/docs/plans/form-action-guard-escada.md b/docs/plans/form-action-guard-escada.md index fc3e5d5f..3dd1f26d 100644 --- a/docs/plans/form-action-guard-escada.md +++ b/docs/plans/form-action-guard-escada.md @@ -1,6 +1,6 @@ # Form-action guard — política na escada, não no filename -Status: registrado +Status: entregue (OPS14 #241, 2026-08-02) Atualizado em: 2026-08-02 Issue: #241 (OPS14) Priority: P2 diff --git a/src/app/(campaign)/campanha/actions/leaderSupporter.ts b/src/app/(campaign)/campanha/actions/leaderSupporter.ts index 84e3b0be..792ebc78 100644 --- a/src/app/(campaign)/campanha/actions/leaderSupporter.ts +++ b/src/app/(campaign)/campanha/actions/leaderSupporter.ts @@ -16,7 +16,7 @@ import type { CampaignUser, Supporter } from '@/payload-types' import { getEngagedLeaderMunicipalityIds, isCampaignLeader } from '@/utilities/campaignAccess' import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' import { requireSupporterRegistrationConsent } from '@/utilities/campaignConsent' -import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' +import { runCampaignFormAction } from '@/utilities/campaignFormActionError' import { LEADER_CONTACTS_HOME } from '@/utilities/campaignPageActor' import { acquireContactPhoneLocks, @@ -53,19 +53,6 @@ const safeActionMessages = [ MUNICIPALITY_OUT_OF_SCOPE_MESSAGE, ] as const -const getLeaderSupporterFormError = ( - error: unknown, - values?: LeaderSupporterFormValues, - revision?: number, -): LeaderSupporterFormState => - mapCampaignFormActionError({ - error, - safeMessages: safeActionMessages, - genericMessage: 'Não foi possível cadastrar o contato. Verifique os dados e tente novamente.', - values, - revision, - }) - const getFreshLeaderActor = async ( payload: Payload, actor: CampaignUser, @@ -155,38 +142,38 @@ export const createLeaderSupporterFormAction = async ( state: LeaderSupporterFormState, formData: FormData, ): Promise => { - let values: LeaderSupporterFormValues | undefined - const revision = (state.revision ?? 0) + 1 - - try { - values = { - name: optionalFormText(formData, 'name'), - phone: optionalFormText(formData, 'phone'), - city: optionalFormText(formData, 'city'), - municipality: optionalFormText(formData, 'municipality'), - } - - const phone = sanitizeBrazilianPhoneInput(values.phone ?? '') - const municipality = nullableRelationshipFormValue(formData, 'municipality') - const input = leaderSupporterCreateSchema.parse({ - name: values.name ?? '', - phone, - city: values.city, - municipality, - consentAccepted: checkboxFormValue(formData, 'consentAccepted') ? true : undefined, - }) - - const supporter = await createLeaderSupporter(input) - revalidatePath(LEADER_CONTACTS_HOME) - - return { - status: 'success', - message: supporter.contactReused - ? 'Contato cadastrado. O celular já existia e foi reutilizado.' - : 'Contato cadastrado com sucesso.', - revision, - } - } catch (error) { - return getLeaderSupporterFormError(error, values, revision) + const values: LeaderSupporterFormValues = { + name: optionalFormText(formData, 'name'), + phone: optionalFormText(formData, 'phone'), + city: optionalFormText(formData, 'city'), + municipality: optionalFormText(formData, 'municipality'), } + + return runCampaignFormAction({ + execute: async () => { + const phone = sanitizeBrazilianPhoneInput(values.phone ?? '') + const municipality = nullableRelationshipFormValue(formData, 'municipality') + const input = leaderSupporterCreateSchema.parse({ + name: values.name ?? '', + phone, + city: values.city, + municipality, + consentAccepted: checkboxFormValue(formData, 'consentAccepted') ? true : undefined, + }) + + const supporter = await createLeaderSupporter(input) + revalidatePath(LEADER_CONTACTS_HOME) + + return { + message: supporter.contactReused + ? 'Contato cadastrado. O celular já existia e foi reutilizado.' + : 'Contato cadastrado com sucesso.', + revision: (state.revision ?? 0) + 1, + } + }, + safeMessages: safeActionMessages, + genericMessage: 'Não foi possível cadastrar o contato. Verifique os dados e tente novamente.', + values, + revision: (state.revision ?? 0) + 1, + }) } diff --git a/src/app/(campaign)/campanha/actions/profile.ts b/src/app/(campaign)/campanha/actions/profile.ts index 5d27a1bb..3da889dc 100644 --- a/src/app/(campaign)/campanha/actions/profile.ts +++ b/src/app/(campaign)/campanha/actions/profile.ts @@ -9,7 +9,7 @@ import { relationshipId } from '@/lib/relationship' import { getCampaignUser } from '@/utilities/campaignAuth' import { CAMPAIGN_SESSION_EXPIRED_MESSAGE, - mapCampaignFormActionError, + runCampaignFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' import { @@ -48,61 +48,48 @@ export const updateCampaignAvatarFormAction = async ( return { fieldErrors: { avatar: ['Selecione uma imagem.'] } } } - try { - assertCampaignAvatarFile(file) - } catch (error) { - return mapCampaignFormActionError({ - error, - genericMessage: 'Não foi possível atualizar a foto.', - safeMessages: CAMPAIGN_AVATAR_SAFE_ERROR_MESSAGES, - }) - } + return runCampaignFormAction({ + execute: async () => { + assertCampaignAvatarFile(file) - const payload = await getPayload({ config }) - const previousAvatarID = relationshipId(user.avatar) + const payload = await getPayload({ config }) + const previousAvatarID = relationshipId(user.avatar) - try { - await withPayloadTransaction(payload, async ({ req }) => { - const media = await payload.create({ - collection: 'media', - data: { alt: user.name }, - file: { - data: Buffer.from(await file.arrayBuffer()), - mimetype: file.type, - name: file.name, - size: file.size, - }, - overrideAccess: true, - req, + await withPayloadTransaction(payload, async ({ req }) => { + const media = await payload.create({ + collection: 'media', + data: { alt: user.name }, + file: { + data: Buffer.from(await file.arrayBuffer()), + mimetype: file.type, + name: file.name, + size: file.size, + }, + overrideAccess: true, + req, + }) + + await payload.update({ + collection: 'campaignUser', + id: user.id, + data: { avatar: media.id }, + depth: 0, + overrideAccess: true, + user, + req, + }) + + if (previousAvatarID !== null && previousAvatarID !== media.id) { + await deleteAvatarMedia(payload, req, previousAvatarID) + } }) - await payload.update({ - collection: 'campaignUser', - id: user.id, - data: { avatar: media.id }, - depth: 0, - overrideAccess: true, - user, - req, - }) - - if (previousAvatarID !== null && previousAvatarID !== media.id) { - await deleteAvatarMedia(payload, req, previousAvatarID) - } - }) - } catch (error) { - return mapCampaignFormActionError({ - error, - genericMessage: 'Não foi possível atualizar a foto.', - safeMessages: CAMPAIGN_AVATAR_SAFE_ERROR_MESSAGES, - }) - } - - revalidatePath('/campanha', 'layout') - return { - status: 'success', - message: 'Foto de perfil atualizada.', - } + revalidatePath('/campanha', 'layout') + return { message: 'Foto de perfil atualizada.' } + }, + safeMessages: CAMPAIGN_AVATAR_SAFE_ERROR_MESSAGES, + genericMessage: 'Não foi possível atualizar a foto.', + }) } export const removeCampaignAvatarFormAction = async ( @@ -119,33 +106,28 @@ export const removeCampaignAvatarFormAction = async ( return { status: 'success', message: 'Foto de perfil removida.' } } - const payload = await getPayload({ config }) + return runCampaignFormAction({ + execute: async () => { + const payload = await getPayload({ config }) - try { - await withPayloadTransaction(payload, async ({ req }) => { - await payload.update({ - collection: 'campaignUser', - id: user.id, - data: { avatar: null }, - depth: 0, - overrideAccess: true, - user, - req, + await withPayloadTransaction(payload, async ({ req }) => { + await payload.update({ + collection: 'campaignUser', + id: user.id, + data: { avatar: null }, + depth: 0, + overrideAccess: true, + user, + req, + }) + + await deleteAvatarMedia(payload, req, previousAvatarID) }) - await deleteAvatarMedia(payload, req, previousAvatarID) - }) - } catch (error) { - return mapCampaignFormActionError({ - error, - genericMessage: 'Não foi possível remover a foto.', - safeMessages: CAMPAIGN_AVATAR_SAFE_ERROR_MESSAGES, - }) - } - - revalidatePath('/campanha', 'layout') - return { - status: 'success', - message: 'Foto de perfil removida.', - } + revalidatePath('/campanha', 'layout') + return { message: 'Foto de perfil removida.' } + }, + safeMessages: CAMPAIGN_AVATAR_SAFE_ERROR_MESSAGES, + genericMessage: 'Não foi possível remover a foto.', + }) } diff --git a/src/app/(campaign)/campanha/convite/[token]/formActions.ts b/src/app/(campaign)/campanha/convite/[token]/formActions.ts index 7904f7dd..f0a07b17 100644 --- a/src/app/(campaign)/campanha/convite/[token]/formActions.ts +++ b/src/app/(campaign)/campanha/convite/[token]/formActions.ts @@ -1,7 +1,5 @@ 'use server' -import { redirect } from 'next/navigation' - import { redeemCampaignInviteAutofill, redeemCampaignInviteLogin, @@ -14,8 +12,8 @@ import { } from '@/lib/formData' import { campaignInviteAutofillSchema, campaignInviteLoginFormSchema } from '@/lib/schemas/invite' import { - mapCampaignFormActionError, runCampaignFormAction, + runCampaignRedirectFormAction, } from '@/utilities/campaignFormActionError' export type CampaignInviteFormState = { @@ -36,13 +34,6 @@ const profileFromForm = (formData: FormData) => ({ gender: nullableFormText(formData, 'gender'), }) -const inviteFormError = (error: unknown): CampaignInviteFormState => - mapCampaignFormActionError({ - error, - genericMessage: - 'Este convite não está disponível. Peça um novo convite à pessoa que falou com você.', - }) - export const redeemCampaignInviteAutofillFormAction = async ( token: string, _state: CampaignInviteFormState, @@ -66,19 +57,19 @@ export const redeemCampaignInviteLoginFormAction = async ( token: string, _state: CampaignInviteFormState, formData: FormData, -): Promise => { - try { - const input = campaignInviteLoginFormSchema.parse({ - token, - ...profileFromForm(formData), - password: requiredFormSecret(formData, 'password'), - passwordConfirmation: requiredFormSecret(formData, 'passwordConfirmation'), - consentAccepted: checkboxFormValue(formData, 'consentAccepted'), - }) - await redeemCampaignInviteLogin(input) - } catch (error) { - return inviteFormError(error) - } - - redirect('/campanha') -} +): Promise => + runCampaignRedirectFormAction({ + execute: async () => { + const input = campaignInviteLoginFormSchema.parse({ + token, + ...profileFromForm(formData), + password: requiredFormSecret(formData, 'password'), + passwordConfirmation: requiredFormSecret(formData, 'passwordConfirmation'), + consentAccepted: checkboxFormValue(formData, 'consentAccepted'), + }) + await redeemCampaignInviteLogin(input) + }, + redirectTo: () => '/campanha', + genericMessage: + 'Este convite não está disponível. Peça um novo convite à pessoa que falou com você.', + }) diff --git a/src/utilities/campaignFormActionError.ts b/src/utilities/campaignFormActionError.ts index e7de669e..4ea62ee1 100644 --- a/src/utilities/campaignFormActionError.ts +++ b/src/utilities/campaignFormActionError.ts @@ -63,10 +63,12 @@ export const mapCampaignFormActionError = ( * 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: + * Documented exceptions that deliberately stay hand-rolled (allowlisted in + * `codebaseConventions.unit.spec.ts`): * `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). + * duplicate-title fallback), `apoiadores/[id]/formActions.ts` (flattens + * field errors into message-only states for its inline controls), + * `actions/auth.ts` and `actions/password.ts` (bespoke login/password flows). */ export const runCampaignFormAction = async < Success extends { message: string }, diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 432ccc9a..32ad2262 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -137,35 +137,46 @@ describe('eslint legacy ignore lists', () => { }) describe('campaign formActions convention', () => { - // 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 + // Any file under `(campaign)` that calls `mapCampaignFormActionError` implements + // the hand-rolled form-action ladder and must go through the shared wrappers + // (`runCampaignFormAction` / `runCampaignRedirectFormAction`) so error mapping + // cannot drift per route (Pass 2 W4d / C8 F4b / OPS14). Filename is not the + // signal — presence of the mapper is. A file that truly cannot use the wrappers // gets an allowlist entry documenting why: - const isGuardedActionFile = (name: string): boolean => - /^\w*[Ff]ormActions\.ts$/.test(name) || name === 'taskActions.ts' - - const allowlist = new Set([ - // 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', + const allowlist = new Map([ // 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', + [ + 'src/app/(campaign)/campanha/(app)/atividades/formActions.ts', + 'unique-violation fieldErrors + async duplicate-title fallback', + ], // Flattens field errors into message-only states for inline detail controls. - 'src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts', + [ + 'src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts', + 'message-only flatten for inline detail controls', + ], + // Login: LockedAuth branch, redirect outside try, NEXT_REDIRECT rethrow. + [ + 'src/app/(campaign)/campanha/actions/auth.ts', + 'bespoke login flow — redirect and LockedAuth outside the shared ladder', + ], + // Password reset/change: anti-enumeration swallow, bespoke field errors, + // NEXT_REDIRECT rethrow — policies the wrappers don't absorb. + [ + 'src/app/(campaign)/campanha/actions/password.ts', + 'anti-enumeration + bespoke password field errors outside the shared ladder', + ], ]) - it('routes every *FormActions / taskActions ladder through a shared wrapper', () => { + it('routes every hand-rolled mapCampaignFormActionError ladder through a shared wrapper', () => { const offenders = walkSourceFiles(resolve(repoRoot, 'src/app/(campaign)'), ['.ts']) - .filter((file) => isGuardedActionFile(basename(file))) + .filter((file) => !/\.spec\.ts$/.test(file)) .map(repoPath) .filter((path) => !allowlist.has(path)) .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') + if (!/\bmapCampaignFormActionError\s*\(/.test(source)) return false + // Require a real call site — a comment naming the wrappers must not pass. return !/\brunCampaign(Redirect)?FormAction\s*\(/.test(source) })