teqo-backup/src/lib/formData.ts
Francisco Solla 6de8fad8f6 feat(C112): múltiplos telefones por pessoa — Contact.phones array (ordem = prioridade)
- Contact.phone (text único) → phones (array field Payload, subcampo value):
  fonte única; principal = phones[0]; beforeValidate normaliza, filtra vazios,
  rejeita inválido e duplicado DENTRO da ficha (C111 entre fichas preservado)
- migration add_contact_phones_array: backfill phone → phones[0] (gen_random_uuid)
  antes do drop da coluna + índice em value (hand-edit documentado; snapshot .json
  gerado mantém as colunas C99/C100 já existentes)
- primaryPhoneOf / phoneValuesOf / reorderWithPrimaryPhone em src/lib/phone.ts
  (ordem = prioridade; inline edit = seta o principal preservando o resto)
- findOrCreateContactByPhone aceita phones[] (dedupe pelo primário; reuse faz
  append dos faltantes; locks em todos os números digitados)
- sync conta→ficha (CampaignUser): phone da conta faz APPEND quando a ficha não
  tem (nunca reordena nem remove); tombstone LGPD substitui TODOS os telefones
- convite redemption: telefone digitado vira principal, demais preservados
- import CSV: preview sinaliza compartilhado também por número secundário;
  bulk insere contact_phones (JOIN nas leituras, uuid no insert)
- busca por telefone (apoiadores, pickers, combobox) casa qualquer número
- UI: PhonesFieldEditor compartilhado (repetível mascarado, remover, ↑/↓,
  modo inline com Salvar) nas 5 superfícies — liderança (form/ficha/lista),
  apoiador (form/ficha), dobradinha (ficha), assessor (ficha), wizard
- UI pública (petição/WhatsApp) intocada: zod e forms ficam com phone;
  as ações mapeiam phone → phones[0]
- testes: spec novo campaignMultiPhones (aceite completo), specs C111/C99
  atualizados, e2e wizard/apoiador ajustados
2026-08-11 10:47:02 -03:00

232 lines
7.9 KiB
TypeScript

import 'server-only'
import { z, type ZodError } from 'zod'
import { MAX_VOTE_COUNT } from '@/lib/schemas/primitives'
import type { VoteEstimateScenarioFields } from '@/lib/voteEstimate'
export class FormDataBoundaryError extends Error {
field: string
constructor(field: string, message: string) {
super(message)
this.field = field
}
}
const formEntry = (
formData: FormData,
field: string,
): { present: false } | { present: true; value: string } => {
if (!formData.has(field)) return { present: false }
const value = formData.get(field)
if (typeof value !== 'string') {
throw new FormDataBoundaryError(field, 'Arquivos não são aceitos neste campo.')
}
return { present: true, value }
}
export const requiredFormText = (formData: FormData, field: string): string => {
const entry = formEntry(formData, field)
const value = entry.present ? entry.value.trim() : ''
if (!value) throw new FormDataBoundaryError(field, 'Campo obrigatório.')
return value
}
/** Membership toggles send `'true'` / `'false'` as form text. */
export const requiredFormBoolean = (formData: FormData, field: string): boolean => {
const value = requiredFormText(formData, field)
if (value === 'true') return true
if (value === 'false') return false
throw new FormDataBoundaryError(field, 'Valor inválido.')
}
export const requiredFormSecret = (formData: FormData, field: string): string => {
const entry = formEntry(formData, field)
const value = entry.present ? entry.value : ''
if (!value) throw new FormDataBoundaryError(field, 'Campo obrigatório.')
return value
}
export const optionalFormText = (formData: FormData, field: string): string | undefined => {
const entry = formEntry(formData, field)
if (!entry.present) return undefined
return entry.value.trim() || undefined
}
/** Hidden `municipalitySlug` on list/detail municipality forms for scoped revalidation. */
export const optionalMunicipalitySlugFromForm = (formData: FormData): string | undefined =>
optionalFormText(formData, 'municipalitySlug')
export const nullableFormText = (formData: FormData, field: string): string | null | undefined => {
const entry = formEntry(formData, field)
if (!entry.present) return undefined
return entry.value.trim() || null
}
const positiveInteger = (field: string, value: string): number => {
const normalized = value.trim()
if (!/^[1-9]\d*$/.test(normalized)) {
throw new FormDataBoundaryError(field, 'Identificador inválido.')
}
const parsed = Number(normalized)
if (!Number.isSafeInteger(parsed)) {
throw new FormDataBoundaryError(field, 'Identificador inválido.')
}
return parsed
}
export const requiredIntegerFormValue = (
formData: FormData,
field: string,
{ minimum = 0, maximum = Number.MAX_SAFE_INTEGER }: { minimum?: number; maximum?: number } = {},
): number => {
const entry = formEntry(formData, field)
const normalized = entry.present ? entry.value.trim() : ''
if (!/^\d+$/.test(normalized)) {
throw new FormDataBoundaryError(field, 'Informe um número inteiro válido.')
}
const parsed = Number(normalized)
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new FormDataBoundaryError(field, 'Informe um número inteiro válido.')
}
return parsed
}
export const optionalIntegerFormValue = (
formData: FormData,
field: string,
options?: { minimum?: number; maximum?: number },
): number | undefined => {
const entry = formEntry(formData, field)
if (!entry.present || !entry.value.trim()) return undefined
return requiredIntegerFormValue(formData, field, options)
}
const optionalNullableIntegerFormValue = (
formData: FormData,
field: string,
options?: { minimum?: number; maximum?: number },
): number | null | undefined => {
const entry = formEntry(formData, field)
if (!entry.present) return undefined
if (!entry.value.trim()) return null
return requiredIntegerFormValue(formData, field, options)
}
export const voteEstimateScenarioFromForm = (
formData: FormData,
prefix: 'estimatedVotes' | 'expectedVotes',
): VoteEstimateScenarioFields => ({
pessimistic:
optionalNullableIntegerFormValue(formData, `${prefix}Pessimistic`, {
minimum: 0,
maximum: MAX_VOTE_COUNT,
}) ?? null,
central:
optionalNullableIntegerFormValue(formData, `${prefix}Central`, {
minimum: 0,
maximum: MAX_VOTE_COUNT,
}) ?? null,
optimistic:
optionalNullableIntegerFormValue(formData, `${prefix}Optimistic`, {
minimum: 0,
maximum: MAX_VOTE_COUNT,
}) ?? null,
})
export const requiredRelationshipFormValue = (formData: FormData, field: string): number => {
const entry = formEntry(formData, field)
if (!entry.present) throw new FormDataBoundaryError(field, 'Campo obrigatório.')
return positiveInteger(field, entry.value)
}
export const nullableRelationshipFormValue = (
formData: FormData,
field: string,
): number | null | undefined => {
const entry = formEntry(formData, field)
if (!entry.present) return undefined
if (!entry.value.trim()) return null
return positiveInteger(field, entry.value)
}
export const repeatedRelationshipFormValues = (formData: FormData, field: string): number[] => {
const values = formData.getAll(field).map((value) => {
if (typeof value !== 'string') {
throw new FormDataBoundaryError(field, 'Arquivos não são aceitos neste campo.')
}
return positiveInteger(field, value)
})
return [...new Set(values)].sort((left, right) => left - right)
}
export const repeatedFormTexts = (formData: FormData, field: string): string[] => {
const seen = new Set<string>()
const values: string[] = []
for (const value of formData.getAll(field)) {
if (typeof value !== 'string') {
throw new FormDataBoundaryError(field, 'Arquivos não são aceitos neste campo.')
}
const trimmed = value.trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
values.push(trimmed)
}
return values
}
/**
* The ficha's phone list from the repeatable editor: order is preserved (=
* priority, first = primary) and empty values are dropped. Duplicates are NOT
* dropped here — the zod `contactPhonesSchema` refine reports them so the
* form can show the "Telefone repetido" refusal instead of silently merging.
*/
export const repeatedPhoneFormValues = (formData: FormData, field: string): string[] => {
const values: string[] = []
for (const value of formData.getAll(field)) {
if (typeof value !== 'string') {
throw new FormDataBoundaryError(field, 'Arquivos não são aceitos neste campo.')
}
const trimmed = value.trim()
if (trimmed) values.push(trimmed)
}
return values
}
export const checkboxFormValue = (formData: FormData, field: string): boolean => {
const entry = formEntry(formData, field)
if (!entry.present) return false
if (entry.value === 'on' || entry.value === 'true' || entry.value === '1') return true
if (entry.value === 'false' || entry.value === '0') return false
throw new FormDataBoundaryError(field, 'Valor de seleção inválido.')
}
export const boundedJsonFormValue = (
formData: FormData,
field: string,
maximumBytes: number,
): unknown | undefined => {
const entry = formEntry(formData, field)
if (!entry.present) return undefined
const raw = entry.value
if (Buffer.byteLength(raw, 'utf8') > maximumBytes) {
throw new FormDataBoundaryError(field, 'Conteúdo excede o tamanho permitido.')
}
const normalized = raw.trim()
if (!normalized) throw new FormDataBoundaryError(field, 'JSON inválido.')
try {
return JSON.parse(normalized) as unknown
} catch {
throw new FormDataBoundaryError(field, 'JSON inválido.')
}
}
export const validationFieldErrors = (error: ZodError): Record<string, string[]> => {
const flattened = z.flattenError(error)
const fieldErrors = {
...flattened.fieldErrors,
} as Record<string, string[]>
if (flattened.formErrors.length > 0) fieldErrors.form = flattened.formErrors
return fieldErrors
}