mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
243 lines
8.3 KiB
TypeScript
243 lines
8.3 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)
|
|
}
|
|
|
|
/**
|
|
* Relationship `hasMany` values from the form, deduped. Order is the value
|
|
* order (ascending id) for the scoped selects; `{ sort: false }` keeps the
|
|
* submission order for the lists where the user's order is meaningful (the
|
|
* S37 people chips — the first name leads the public card).
|
|
*/
|
|
export const repeatedRelationshipFormValues = (
|
|
formData: FormData,
|
|
field: string,
|
|
{ sort = true }: { sort?: boolean } = {},
|
|
): 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)
|
|
})
|
|
const unique = [...new Set(values)]
|
|
return sort ? unique.sort((left, right) => left - right) : unique
|
|
}
|
|
|
|
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
|
|
}
|