mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
- 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
232 lines
7.9 KiB
TypeScript
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
|
|
}
|