mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
OPS14 — form-action guard sweeps ladder, not filename
Replace the W4d filename-based convention spec with a sweep of every src/app/(campaign) file that calls mapCampaignFormActionError without runCampaignFormAction/Redirect. Migrate profile, leaderSupporter, and convite-login to the shared wrappers; allowlist auth/password and the existing bespoke formActions exceptions with documented reasons. Closes #241 Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
parent
f66381e09f
commit
62b6001ed8
8 changed files with 148 additions and 175 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<LeaderSupporterFormState> => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CampaignInviteFormState> => {
|
||||
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<CampaignInviteFormState> =>
|
||||
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ê.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -63,10 +63,12 @@ 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:
|
||||
* 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 },
|
||||
|
|
|
|||
|
|
@ -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<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',
|
||||
const allowlist = new Map<string, string>([
|
||||
// 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)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue