diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts index 5ca388ac..44258639 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts @@ -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' diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts index 81640903..da6a06a9 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts @@ -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 => { - 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, + }) } diff --git a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts index 7af36a20..34cbf9a0 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts @@ -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 => { - try { - const id = requiredRelationshipFormValue(formData, 'advisorId') - const field = requiredFormText(formData, 'field') +): Promise => + 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 => { - 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 => + 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 => { - 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 => + 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 => { - 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 => + 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 => { - 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 => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts b/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts index 9ed09e01..e7603511 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts @@ -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 => { - try { - const rawStatus = requiredFormText(formData, 'status') - if (!campaignDemandStatuses.includes(rawStatus as CampaignDemandStatus)) { - return { message: 'Status de demanda inválido.' } - } +): Promise => + 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 => { - 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 => + 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 => { - try { - const demandID = requiredRelationshipFormValue(formData, 'demandId') - const file = formData.get('receipt') - if (!(file instanceof File)) { - return { fieldErrors: { receipt: ['Selecione um arquivo.'] } } - } +): Promise => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts index 0cfa22d4..6194fa99 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts @@ -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 => { - 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 => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/liderancas/[id]/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/[id]/formActions.ts index 0b6352be..30570cc2 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/[id]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/[id]/formActions.ts @@ -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 => { - try { - const sector = optionalFormText(formData, 'sector') - const supportStatus = optionalFormText(formData, 'supportStatus') +): Promise => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts b/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts index 919ee825..ec4a942c 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts @@ -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 => { - try { - const municipality = requiredRelationshipFormValue(formData, 'municipalityId') - const priority = optionalFormText(formData, 'priority') +): Promise => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/formActions.ts b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/formActions.ts index 8c7f0e8d..0dbda46e 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/formActions.ts @@ -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 => { - 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 => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/planos/formActions.ts b/src/app/(campaign)/campanha/(app)/planos/formActions.ts index a76fc8b2..d7a67032 100644 --- a/src/app/(campaign)/campanha/(app)/planos/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/planos/formActions.ts @@ -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' diff --git a/src/app/(campaign)/campanha/convite/[token]/formActions.ts b/src/app/(campaign)/campanha/convite/[token]/formActions.ts index 9b4523b2..2d3d3db7 100644 --- a/src/app/(campaign)/campanha/convite/[token]/formActions.ts +++ b/src/app/(campaign)/campanha/convite/[token]/formActions.ts @@ -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 => { - 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 => + 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, diff --git a/src/utilities/campaignFormActionError.ts b/src/utilities/campaignFormActionError.ts index 6a285631..e7e0a91e 100644 --- a/src/utilities/campaignFormActionError.ts +++ b/src/utilities/campaignFormActionError.ts @@ -50,6 +50,52 @@ export const mapCampaignFormActionError = ( 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 + 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> => { + 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 diff --git a/tests/unit/campaignFormActions.unit.spec.ts b/tests/unit/campaignFormActions.unit.spec.ts new file mode 100644 index 00000000..bfe6d59d --- /dev/null +++ b/tests/unit/campaignFormActions.unit.spec.ts @@ -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 }) + }) +})