Pass 2 W4d: runCampaignFormAction (stay-on-page) fecha o C8 F4

- novo wrapper ao lado de runCampaignRedirectFormAction: execute -> success spread (message + extras tipo advisorId/supporterId); erro mapeado por mapCampaignFormActionError com echo de values/revision
- 13 acoes migradas em 7 arquivos: dobradinhas/[slug], organizacoes/[slug], municipios editar, liderancas/[id], assessores (5), demandas/[slug] (3), apoiadores/novo, convite autofill
- early-returns viram throws: mensagens seguras (Campo invalido, Selecione ao menos um municipio, Status invalido) e FormDataBoundaryError p/ erros de campo (cost, receipt) - estados visiveis identicos
- excecoes documentadas em comentario: planos (mapper custom + fallback async de titulo duplicado), apoiadores/[id] (flatten p/ controles message-only), convite login (duas fases + redirect)
- pin unitario do wrapper: campaignFormActions.unit.spec.ts (spread, boundary, zod, safe/generic, echo)
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 15:33:19 -03:00
parent c52e0e7957
commit 267218b05a
12 changed files with 450 additions and 352 deletions

View file

@ -1,5 +1,9 @@
'use server'
// Documented exception to `runCampaignFormAction` (Pass 2 W4d): these actions
// feed message-only inline controls, so every mapped state (field errors
// included) is flattened to a single message via `toMessageOnlyState`.
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

View file

@ -9,7 +9,7 @@ import {
optionalFormText,
} from '@/lib/formData'
import { supporterCreateSchema } from '@/lib/schemas/supporter'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import { runCampaignFormAction } from '@/utilities/campaignFormActionError'
import { sanitizeBrazilianPhoneInput } from '@/lib/phone'
export type SupporterFormState = {
@ -39,64 +39,52 @@ const safeActionMessages = [
'Consentimento de intenção de voto ainda não configurado.',
] as const
const getSupporterFormError = (
error: unknown,
values?: SupporterFormValues,
revision?: number,
): SupporterFormState =>
mapCampaignFormActionError({
error,
safeMessages: safeActionMessages,
genericMessage: 'Não foi possível cadastrar o apoiador. Verifique os dados e tente novamente.',
values,
revision,
})
export const createSupporterFormAction = async (
state: SupporterFormState,
formData: FormData,
): Promise<SupporterFormState> => {
let values: SupporterFormValues | undefined
const revision = (state.revision ?? 0) + 1
try {
values = {
name: optionalFormText(formData, 'name'),
phone: optionalFormText(formData, 'phone'),
email: optionalFormText(formData, 'email'),
city: optionalFormText(formData, 'city'),
municipality: optionalFormText(formData, 'municipality'),
voteIntention: optionalFormText(formData, 'voteIntention'),
}
const phone = sanitizeBrazilianPhoneInput(values.phone ?? '')
const voteIntention = values.voteIntention?.trim() || undefined
const municipality = nullableRelationshipFormValue(formData, 'municipality')
const input = supporterCreateSchema.parse({
name: values.name ?? '',
phone,
email: values.email,
city: values.city,
...(municipality ? { municipality } : {}),
...(voteIntention ? { voteIntention } : {}),
consentAccepted: checkboxFormValue(formData, 'consentAccepted') ? true : undefined,
voteIntentionConsentAccepted: voteIntention
? checkboxFormValue(formData, 'voteIntentionConsentAccepted')
: undefined,
})
const supporter = await createSupporter(input)
revalidatePath('/campanha/apoiadores')
return {
status: 'success',
message: supporter.contactReused
? 'Apoiador cadastrado. O contato existente com este celular foi reutilizado.'
: 'Apoiador cadastrado com sucesso.',
supporterId: supporter.id,
}
} catch (error) {
return getSupporterFormError(error, values, revision)
// Parsed before the ladder (non-throwing readers) so failures echo them back.
const values: SupporterFormValues = {
name: optionalFormText(formData, 'name'),
phone: optionalFormText(formData, 'phone'),
email: optionalFormText(formData, 'email'),
city: optionalFormText(formData, 'city'),
municipality: optionalFormText(formData, 'municipality'),
voteIntention: optionalFormText(formData, 'voteIntention'),
}
return runCampaignFormAction({
execute: async () => {
const phone = sanitizeBrazilianPhoneInput(values.phone ?? '')
const voteIntention = values.voteIntention?.trim() || undefined
const municipality = nullableRelationshipFormValue(formData, 'municipality')
const input = supporterCreateSchema.parse({
name: values.name ?? '',
phone,
email: values.email,
city: values.city,
...(municipality ? { municipality } : {}),
...(voteIntention ? { voteIntention } : {}),
consentAccepted: checkboxFormValue(formData, 'consentAccepted') ? true : undefined,
voteIntentionConsentAccepted: voteIntention
? checkboxFormValue(formData, 'voteIntentionConsentAccepted')
: undefined,
})
const supporter = await createSupporter(input)
revalidatePath('/campanha/apoiadores')
return {
message: supporter.contactReused
? 'Apoiador cadastrado. O contato existente com este celular foi reutilizado.'
: 'Apoiador cadastrado com sucesso.',
supporterId: supporter.id,
}
},
safeMessages: safeActionMessages,
genericMessage: 'Não foi possível cadastrar o apoiador. Verifique os dados e tente novamente.',
values,
revision: (state.revision ?? 0) + 1,
})
}

View file

@ -14,10 +14,19 @@ import {
} from '@/lib/formData'
import { ADVISOR_ACTION_SAFE_MESSAGES } from '@/lib/schemas/advisor'
import {
mapCampaignFormActionError,
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
const INVALID_FIELD_MESSAGE = 'Campo inválido.'
const EMPTY_BATCH_MESSAGE = 'Selecione ao menos um município.'
const advisorSafeMessages = [
...ADVISOR_ACTION_SAFE_MESSAGES,
INVALID_FIELD_MESSAGE,
EMPTY_BATCH_MESSAGE,
] as const
const parseMunicipalityIds = (formData: FormData): number[] => {
const raw = formData.getAll('municipalityIds')
const ids = raw
@ -29,131 +38,109 @@ const parseMunicipalityIds = (formData: FormData): number[] => {
export const updateAdvisorProfileFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const id = requiredRelationshipFormValue(formData, 'advisorId')
const field = requiredFormText(formData, 'field')
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const id = requiredRelationshipFormValue(formData, 'advisorId')
const field = requiredFormText(formData, 'field')
if (field === 'name') {
await updateAdvisorProfile({ id, name: requiredFormText(formData, 'name') })
} else if (field === 'email') {
await updateAdvisorProfile({ id, email: requiredFormText(formData, 'email') })
} else if (field === 'phone') {
await updateAdvisorProfile({
id,
phone: nullableFormText(formData, 'phone'),
})
} else {
return { message: 'Campo inválido.' }
}
if (field === 'name') {
await updateAdvisorProfile({ id, name: requiredFormText(formData, 'name') })
} else if (field === 'email') {
await updateAdvisorProfile({ id, email: requiredFormText(formData, 'email') })
} else if (field === 'phone') {
await updateAdvisorProfile({
id,
phone: nullableFormText(formData, 'phone'),
})
} else {
throw new Error(INVALID_FIELD_MESSAGE)
}
return { status: 'success', message: 'Salvo.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ADVISOR_ACTION_SAFE_MESSAGES,
genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.',
})
}
}
return { message: 'Salvo.' }
},
safeMessages: advisorSafeMessages,
genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.',
})
export const setAdvisorMunicipalityMembershipFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const assignedRaw = requiredFormText(formData, 'assigned')
await setAdvisorMunicipalityMembership({
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
municipalityId: requiredRelationshipFormValue(formData, 'municipalityId'),
assigned: assignedRaw === 'true',
})
return { status: 'success', message: 'Carteira atualizada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ADVISOR_ACTION_SAFE_MESSAGES,
genericMessage: 'Não foi possível atualizar a carteira do assessor.',
})
}
}
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const assignedRaw = requiredFormText(formData, 'assigned')
await setAdvisorMunicipalityMembership({
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
municipalityId: requiredRelationshipFormValue(formData, 'municipalityId'),
assigned: assignedRaw === 'true',
})
return { message: 'Carteira atualizada.' }
},
safeMessages: advisorSafeMessages,
genericMessage: 'Não foi possível atualizar a carteira do assessor.',
})
export const setAdvisorMunicipalitiesBatchFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const municipalityIds = parseMunicipalityIds(formData)
if (municipalityIds.length === 0) {
return { message: 'Selecione ao menos um município.' }
}
const assignedRaw = requiredFormText(formData, 'assigned')
await setAdvisorMunicipalitiesBatch({
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
municipalityIds,
assigned: assignedRaw === 'true',
})
return { status: 'success', message: 'Carteira atualizada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ADVISOR_ACTION_SAFE_MESSAGES,
genericMessage: 'Não foi possível atualizar a carteira do assessor.',
})
}
}
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const municipalityIds = parseMunicipalityIds(formData)
if (municipalityIds.length === 0) {
throw new Error(EMPTY_BATCH_MESSAGE)
}
const assignedRaw = requiredFormText(formData, 'assigned')
await setAdvisorMunicipalitiesBatch({
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
municipalityIds,
assigned: assignedRaw === 'true',
})
return { message: 'Carteira atualizada.' }
},
safeMessages: advisorSafeMessages,
genericMessage: 'Não foi possível atualizar a carteira do assessor.',
})
export const sendAdvisorPasswordResetFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const result = await sendAdvisorPasswordReset({
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
})
return {
status: 'success',
message: `Link de redefinição enviado para ${result.email}.`,
}
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ADVISOR_ACTION_SAFE_MESSAGES,
genericMessage: 'Não foi possível enviar o link de redefinição de senha.',
})
}
}
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const result = await sendAdvisorPasswordReset({
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
})
return { message: `Link de redefinição enviado para ${result.email}.` }
},
safeMessages: advisorSafeMessages,
genericMessage: 'Não foi possível enviar o link de redefinição de senha.',
})
export const createAdvisorFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState & { advisorId?: number }> => {
try {
const created = await createAdvisor({
name: requiredFormText(formData, 'name'),
email: requiredFormText(formData, 'email'),
phone: nullableFormText(formData, 'phone') ?? undefined,
})
const municipalityIds = parseMunicipalityIds(formData)
if (municipalityIds.length > 0) {
await setAdvisorMunicipalitiesBatch({
advisorId: created.id,
municipalityIds,
assigned: true,
): Promise<CampaignFormActionState & { advisorId?: number }> =>
runCampaignFormAction({
execute: async () => {
const created = await createAdvisor({
name: requiredFormText(formData, 'name'),
email: requiredFormText(formData, 'email'),
phone: nullableFormText(formData, 'phone') ?? undefined,
})
}
return {
status: 'success',
message: 'Assessor criado.',
advisorId: created.id,
}
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ADVISOR_ACTION_SAFE_MESSAGES,
genericMessage: 'Não foi possível criar o assessor. Verifique os dados e tente novamente.',
})
}
}
const municipalityIds = parseMunicipalityIds(formData)
if (municipalityIds.length > 0) {
await setAdvisorMunicipalitiesBatch({
advisorId: created.id,
municipalityIds,
assigned: true,
})
}
return { message: 'Assessor criado.', advisorId: created.id }
},
safeMessages: advisorSafeMessages,
genericMessage: 'Não foi possível criar o assessor. Verifique os dados e tente novamente.',
})

View file

@ -7,100 +7,99 @@ import {
setCampaignDemandCost,
transitionCampaignDemand,
} from '@/app/(campaign)/campanha/actions/demand'
import { nullableFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import {
FormDataBoundaryError,
nullableFormText,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { campaignDemandStatuses, type CampaignDemandStatus } from '@/lib/schemas/campaignDemand'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import {
mapCampaignFormActionError,
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
const INVALID_STATUS_MESSAGE = 'Status de demanda inválido.'
const transitionSafeMessages = [
'Somente a coordenação e a assessoria movem demandas.',
'Demandas escaladas são decididas pelo Coordenador Geral.',
INVALID_STATUS_MESSAGE,
] as const
export const transitionDemandFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const rawStatus = requiredFormText(formData, 'status')
if (!campaignDemandStatuses.includes(rawStatus as CampaignDemandStatus)) {
return { message: 'Status de demanda inválido.' }
}
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const rawStatus = requiredFormText(formData, 'status')
if (!campaignDemandStatuses.includes(rawStatus as CampaignDemandStatus)) {
throw new Error(INVALID_STATUS_MESSAGE)
}
await transitionCampaignDemand({
id: requiredRelationshipFormValue(formData, 'demandId'),
status: rawStatus as CampaignDemandStatus,
decisionNote: nullableFormText(formData, 'decisionNote'),
})
revalidatePath('/campanha/demandas/[slug]', 'page')
return { status: 'success', message: 'Demanda atualizada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: transitionSafeMessages,
genericMessage: 'Não foi possível mover a demanda. Verifique seu acesso e tente novamente.',
})
}
}
await transitionCampaignDemand({
id: requiredRelationshipFormValue(formData, 'demandId'),
status: rawStatus as CampaignDemandStatus,
decisionNote: nullableFormText(formData, 'decisionNote'),
})
revalidatePath('/campanha/demandas/[slug]', 'page')
return { message: 'Demanda atualizada.' }
},
safeMessages: transitionSafeMessages,
genericMessage: 'Não foi possível mover a demanda. Verifique seu acesso e tente novamente.',
})
export const setDemandCostFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const rawCost = formData.get('cost')
let cost: number | null = null
if (typeof rawCost === 'string' && rawCost.trim()) {
const parsed = Number(rawCost.replace(',', '.'))
if (!Number.isFinite(parsed) || parsed < 0) {
return { fieldErrors: { cost: ['Informe um valor válido.'] } }
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const rawCost = formData.get('cost')
let cost: number | null = null
if (typeof rawCost === 'string' && rawCost.trim()) {
const parsed = Number(rawCost.replace(',', '.'))
if (!Number.isFinite(parsed) || parsed < 0) {
throw new FormDataBoundaryError('cost', 'Informe um valor válido.')
}
cost = Math.round(parsed * 100) / 100
}
cost = Math.round(parsed * 100) / 100
}
await setCampaignDemandCost({
id: requiredRelationshipFormValue(formData, 'demandId'),
cost,
})
revalidatePath('/campanha/demandas/[slug]', 'page')
return { status: 'success', message: 'Custo registrado.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente a coordenação e a assessoria registram custos.'],
genericMessage: 'Não foi possível registrar o custo. Tente novamente.',
})
}
}
await setCampaignDemandCost({
id: requiredRelationshipFormValue(formData, 'demandId'),
cost,
})
revalidatePath('/campanha/demandas/[slug]', 'page')
return { message: 'Custo registrado.' }
},
safeMessages: ['Somente a coordenação e a assessoria registram custos.'],
genericMessage: 'Não foi possível registrar o custo. Tente novamente.',
})
export const attachDemandReceiptFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const demandID = requiredRelationshipFormValue(formData, 'demandId')
const file = formData.get('receipt')
if (!(file instanceof File)) {
return { fieldErrors: { receipt: ['Selecione um arquivo.'] } }
}
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const demandID = requiredRelationshipFormValue(formData, 'demandId')
const file = formData.get('receipt')
if (!(file instanceof File)) {
throw new FormDataBoundaryError('receipt', 'Selecione um arquivo.')
}
const { payload, actor } = await getCampaignActionContext()
await attachCampaignDemandReceiptRecord(payload, actor, demandID, file)
revalidatePath('/campanha/demandas/[slug]', 'page')
return { status: 'success', message: 'Comprovante anexado.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: [
'Envie uma imagem (JPEG, PNG, WebP) ou PDF.',
'O arquivo enviado está vazio.',
'O comprovante deve ter no máximo 10 MB.',
'Somente a coordenação e a assessoria anexam comprovantes.',
],
genericMessage: 'Não foi possível anexar o comprovante. Tente novamente.',
})
}
}
const { payload, actor } = await getCampaignActionContext()
await attachCampaignDemandReceiptRecord(payload, actor, demandID, file)
revalidatePath('/campanha/demandas/[slug]', 'page')
return { message: 'Comprovante anexado.' }
},
safeMessages: [
'Envie uma imagem (JPEG, PNG, WebP) ou PDF.',
'O arquivo enviado está vazio.',
'O comprovante deve ter no máximo 10 MB.',
'Somente a coordenação e a assessoria anexam comprovantes.',
],
genericMessage: 'Não foi possível anexar o comprovante. Tente novamente.',
})

View file

@ -5,28 +5,25 @@ import { revalidatePath } from 'next/cache'
import { updateStateDeputy } from '@/app/(campaign)/campanha/actions/stateDeputy'
import { nullableFormText, requiredRelationshipFormValue } from '@/lib/formData'
import {
mapCampaignFormActionError,
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
export const updateStateDeputyFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
await updateStateDeputy({
id: requiredRelationshipFormValue(formData, 'stateDeputyId'),
party: nullableFormText(formData, 'party'),
notes: nullableFormText(formData, 'notes'),
})
revalidatePath('/campanha/dobradinhas/[slug]', 'page')
return { status: 'success', message: 'Dobradinha atualizada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente a coordenação e a assessoria gerenciam dobradinhas.'],
genericMessage:
'Não foi possível salvar a dobradinha. Verifique seu acesso e tente novamente.',
})
}
}
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await updateStateDeputy({
id: requiredRelationshipFormValue(formData, 'stateDeputyId'),
party: nullableFormText(formData, 'party'),
notes: nullableFormText(formData, 'notes'),
})
revalidatePath('/campanha/dobradinhas/[slug]', 'page')
return { message: 'Dobradinha atualizada.' }
},
safeMessages: ['Somente a coordenação e a assessoria gerenciam dobradinhas.'],
genericMessage:
'Não foi possível salvar a dobradinha. Verifique seu acesso e tente novamente.',
})

View file

@ -11,7 +11,7 @@ import {
} from '@/lib/formData'
import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership'
import {
mapCampaignFormActionError,
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
@ -23,34 +23,31 @@ const safeMessages = [
export const updateLeadershipInternalFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const sector = optionalFormText(formData, 'sector')
const supportStatus = optionalFormText(formData, 'supportStatus')
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const sector = optionalFormText(formData, 'sector')
const supportStatus = optionalFormText(formData, 'supportStatus')
await updateLeadershipInternal({
id: requiredRelationshipFormValue(formData, 'leadershipId'),
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
stateDeputies: repeatedRelationshipFormValues(formData, 'stateDeputies'),
sector: leadershipSectors.includes(sector as (typeof leadershipSectors)[number])
? (sector as (typeof leadershipSectors)[number])
: null,
supportStatus: leadershipSupportStatuses.includes(
supportStatus as (typeof leadershipSupportStatuses)[number],
)
? (supportStatus as (typeof leadershipSupportStatuses)[number])
: undefined,
notes: nullableFormText(formData, 'notes'),
consentNote: nullableFormText(formData, 'consentNote'),
})
revalidatePath('/campanha/liderancas/[id]', 'page')
return { status: 'success', message: 'Ficha da liderança atualizada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages,
genericMessage: 'Não foi possível salvar a ficha. Verifique seu acesso e tente novamente.',
})
}
}
await updateLeadershipInternal({
id: requiredRelationshipFormValue(formData, 'leadershipId'),
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
stateDeputies: repeatedRelationshipFormValues(formData, 'stateDeputies'),
sector: leadershipSectors.includes(sector as (typeof leadershipSectors)[number])
? (sector as (typeof leadershipSectors)[number])
: null,
supportStatus: leadershipSupportStatuses.includes(
supportStatus as (typeof leadershipSupportStatuses)[number],
)
? (supportStatus as (typeof leadershipSupportStatuses)[number])
: undefined,
notes: nullableFormText(formData, 'notes'),
consentNote: nullableFormText(formData, 'consentNote'),
})
revalidatePath('/campanha/liderancas/[id]', 'page')
return { message: 'Ficha da liderança atualizada.' }
},
safeMessages,
genericMessage: 'Não foi possível salvar a ficha. Verifique seu acesso e tente novamente.',
})

View file

@ -10,7 +10,7 @@ import {
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
mapCampaignFormActionError,
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import { revalidateMunicipalityListPaths } from '@/utilities/municipalityRevalidation'
@ -19,29 +19,26 @@ import { municipalityStaffEditSafeMessages } from '../../municipalityStaffEditMe
export const updateMunicipalityStrategyFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const priority = optionalFormText(formData, 'priority')
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const priority = optionalFormText(formData, 'priority')
await updateMunicipalityStrategy({
municipality,
priority: priority === 'alta' ? 'alta' : 'normal',
strengths: repeatedFormTexts(formData, 'strengths'),
risks: repeatedFormTexts(formData, 'risks'),
stateDeputies: repeatedRelationshipFormValues(formData, 'stateDeputies'),
dobradinhaNotes: nullableFormText(formData, 'dobradinhaNotes'),
nextSteps: nullableFormText(formData, 'nextSteps'),
budgetNotes: nullableFormText(formData, 'budgetNotes'),
})
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { status: 'success', message: 'Estratégia atualizada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: municipalityStaffEditSafeMessages,
genericMessage:
'Não foi possível salvar a estratégia. Verifique seu acesso e tente novamente.',
})
}
}
await updateMunicipalityStrategy({
municipality,
priority: priority === 'alta' ? 'alta' : 'normal',
strengths: repeatedFormTexts(formData, 'strengths'),
risks: repeatedFormTexts(formData, 'risks'),
stateDeputies: repeatedRelationshipFormValues(formData, 'stateDeputies'),
dobradinhaNotes: nullableFormText(formData, 'dobradinhaNotes'),
nextSteps: nullableFormText(formData, 'nextSteps'),
budgetNotes: nullableFormText(formData, 'budgetNotes'),
})
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { message: 'Estratégia atualizada.' }
},
safeMessages: municipalityStaffEditSafeMessages,
genericMessage:
'Não foi possível salvar a estratégia. Verifique seu acesso e tente novamente.',
})

View file

@ -11,32 +11,29 @@ import {
} from '@/lib/formData'
import { organizationKinds, type OrganizationKind } from '@/lib/schemas/organization'
import {
mapCampaignFormActionError,
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
export const updateOrganizationFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const rawKind = optionalFormText(formData, 'kind')
await updateOrganization({
id: requiredRelationshipFormValue(formData, 'organizationId'),
kind: organizationKinds.includes(rawKind as OrganizationKind)
? (rawKind as OrganizationKind)
: undefined,
notes: nullableFormText(formData, 'notes'),
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
})
revalidatePath('/campanha/organizacoes/[slug]', 'page')
return { status: 'success', message: 'Organização atualizada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente a coordenação e a assessoria gerenciam organizações.'],
genericMessage:
'Não foi possível salvar a organização. Verifique seu acesso e tente novamente.',
})
}
}
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const rawKind = optionalFormText(formData, 'kind')
await updateOrganization({
id: requiredRelationshipFormValue(formData, 'organizationId'),
kind: organizationKinds.includes(rawKind as OrganizationKind)
? (rawKind as OrganizationKind)
: undefined,
notes: nullableFormText(formData, 'notes'),
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
})
revalidatePath('/campanha/organizacoes/[slug]', 'page')
return { message: 'Organização atualizada.' }
},
safeMessages: ['Somente a coordenação e a assessoria gerenciam organizações.'],
genericMessage:
'Não foi possível salvar a organização. Verifique seu acesso e tente novamente.',
})

View file

@ -1,5 +1,11 @@
'use server'
// Documented exception to `runCampaignRedirectFormAction`/`runCampaignFormAction`
// (Pass 2 W4d): these ladders carry a custom error mapper (DB unique-violation
// regexes surfacing as field errors) plus an async duplicate-title fallback
// that links to the existing plan — policy the shared wrappers deliberately
// don't grow parameters for.
import config from '@payload-config'
import { getPayload } from 'payload'
import { redirect } from 'next/navigation'

View file

@ -13,7 +13,10 @@ import {
requiredFormSecret,
requiredFormText,
} from '@/lib/formData'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import {
mapCampaignFormActionError,
runCampaignFormAction,
} from '@/utilities/campaignFormActionError'
export type CampaignInviteFormState = {
status?: 'success'
@ -46,22 +49,20 @@ export const redeemCampaignInviteAutofillFormAction = async (
token: string,
_state: CampaignInviteFormState,
formData: FormData,
): Promise<CampaignInviteFormState> => {
try {
const input = campaignInviteAutofillSchema.parse({
token,
...profileFromForm(formData),
consentAccepted: checkboxFormValue(formData, 'consentAccepted'),
})
await redeemCampaignInviteAutofill(input)
return {
status: 'success',
message: 'Seus dados foram confirmados com sucesso.',
}
} catch (error) {
return inviteFormError(error)
}
}
): Promise<CampaignInviteFormState> =>
runCampaignFormAction({
execute: async () => {
const input = campaignInviteAutofillSchema.parse({
token,
...profileFromForm(formData),
consentAccepted: checkboxFormValue(formData, 'consentAccepted'),
})
await redeemCampaignInviteAutofill(input)
return { message: 'Seus dados foram confirmados com sucesso.' }
},
genericMessage:
'Este convite não está disponível. Peça um novo convite à pessoa que falou com você.',
})
export const redeemCampaignInviteLoginFormAction = async (
token: string,

View file

@ -50,6 +50,52 @@ export const mapCampaignFormActionError = <Values>(
return { message: genericMessage, values, revision }
}
/**
* The shared stay-on-page form-action ladder: parse + mutate inside
* `execute` (thrown `FormDataBoundaryError`/`ZodError`/safe messages map to
* the standard error state), spread whatever `execute` resolves into the
* 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 planos
* ladders (custom unique-violation mapping + async duplicate-title fallback)
* and apoiadores/[id] (flattens field errors into message-only states for
* its inline controls).
*/
export const runCampaignFormAction = async <
Success extends { message: string },
Values = undefined,
>({
execute,
safeMessages,
genericMessage,
values,
revision,
resolveBoundaryMessage,
}: {
execute: () => Promise<Success>
safeMessages?: readonly string[]
genericMessage: string
/** Echoed back on failure so the form can repopulate. */
values?: Values
revision?: number
resolveBoundaryMessage?: (error: FormDataBoundaryError) => string
}): Promise<({ status: 'success' } & Success) | CampaignFormErrorState<Values>> => {
try {
const success = await execute()
return { ...success, status: 'success' as const }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages,
genericMessage,
values,
revision,
resolveBoundaryMessage,
})
}
}
/**
* The shared create→redirect form-action ladder: run the mutation, map any
* failure through `mapCampaignFormActionError`, redirect on success. The

View file

@ -0,0 +1,79 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { FormDataBoundaryError } from '@/lib/formData'
import { runCampaignFormAction } from '@/utilities/campaignFormActionError'
const GENERIC = 'Não foi possível concluir a ação.'
describe('runCampaignFormAction (shared stay-on-page ladder)', () => {
it('spreads the execute result into the success state', async () => {
const state = await runCampaignFormAction({
execute: async () => ({ message: 'Salvo.', advisorId: 42 }),
genericMessage: GENERIC,
})
expect(state).toEqual({ status: 'success', message: 'Salvo.', advisorId: 42 })
})
it('maps FormDataBoundaryError to a field error', async () => {
const state = await runCampaignFormAction({
execute: async () => {
throw new FormDataBoundaryError('cost', 'Informe um valor válido.')
},
genericMessage: GENERIC,
})
expect(state).toEqual({
fieldErrors: { cost: ['Informe um valor válido.'] },
values: undefined,
revision: undefined,
})
})
it('maps ZodError to per-field validation errors', async () => {
const schema = z.object({ name: z.string().min(2, 'Informe o nome.') })
const state = await runCampaignFormAction({
execute: async () => {
schema.parse({ name: '' })
return { message: 'nunca chega' }
},
genericMessage: GENERIC,
})
expect(state).toMatchObject({ fieldErrors: { name: ['Informe o nome.'] } })
expect(state).not.toHaveProperty('status')
})
it('echoes safe-listed error messages and hides everything else behind the generic one', async () => {
const safe = await runCampaignFormAction({
execute: async () => {
throw new Error('Somente a coordenação gerencia isto.')
},
safeMessages: ['Somente a coordenação gerencia isto.'],
genericMessage: GENERIC,
})
expect(safe.message).toBe('Somente a coordenação gerencia isto.')
const leaked = await runCampaignFormAction({
execute: async () => {
throw new Error('duplicate key value violates unique constraint "contact_phone_idx"')
},
safeMessages: ['Somente a coordenação gerencia isto.'],
genericMessage: GENERIC,
})
expect(leaked.message).toBe(GENERIC)
})
it('echoes pre-parsed values and the revision on failure so forms repopulate', async () => {
const values = { name: 'Ana', phone: '71 9 9999-0000' }
const state = await runCampaignFormAction({
execute: async () => {
throw new Error('boom')
},
genericMessage: GENERIC,
values,
revision: 3,
})
expect(state).toEqual({ message: GENERIC, values, revision: 3 })
})
})