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:
parent
c52e0e7957
commit
267218b05a
12 changed files with 450 additions and 352 deletions
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
79
tests/unit/campaignFormActions.unit.spec.ts
Normal file
79
tests/unit/campaignFormActions.unit.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue