mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:05:19 +00:00
251 lines
9 KiB
TypeScript
251 lines
9 KiB
TypeScript
import 'server-only'
|
|
|
|
import { allDayStartInstant, isCivilDate } from '@/lib/activityAllDay'
|
|
import { parseBahiaDateTimeInput } from '@/lib/campaignTime'
|
|
import {
|
|
boundedJsonFormValue,
|
|
checkboxFormValue,
|
|
FormDataBoundaryError,
|
|
nullableRelationshipFormValue,
|
|
optionalFormText,
|
|
repeatedRelationshipFormValues,
|
|
requiredFormText,
|
|
requiredRelationshipFormValue,
|
|
} from '@/lib/formData'
|
|
import {
|
|
activityDemandDraftsSchema,
|
|
activityResponsibleSchema,
|
|
activityStatuses,
|
|
MAX_ACTIVITY_DEMAND_DRAFTS,
|
|
MAX_ACTIVITY_RESPONSIBLES,
|
|
MAX_ACTIVITY_TAG_LENGTH,
|
|
MAX_ACTIVITY_TAGS,
|
|
tourStopDraftsSchema,
|
|
type ActivityCreateInput,
|
|
type ActivityDemandDraft,
|
|
type ActivityResponsibleCollection,
|
|
type ActivityUpdateInput,
|
|
type TourStopDraft,
|
|
} from '@/lib/schemas/activity'
|
|
import { MAX_TOUR_NAME_LENGTH, TOUR_EMPTY_MESSAGE } from '@/utilities/visit/visitPlannerViews'
|
|
|
|
type ParsedActivityTask = {
|
|
title: string
|
|
responsible?: number
|
|
done?: boolean
|
|
}
|
|
|
|
const parseScheduleFormField = (
|
|
formData: FormData,
|
|
field: string,
|
|
allDay: boolean,
|
|
): string | undefined => {
|
|
const value = optionalFormText(formData, field)
|
|
if (!value) return undefined
|
|
if (allDay) {
|
|
// C104 — with the toggle on the inputs are date-only civil values; the
|
|
// stored instants come from the all-day convention (00:00 Bahia).
|
|
if (!isCivilDate(value)) throw new FormDataBoundaryError(field, 'Data inválida.')
|
|
return allDayStartInstant(value)
|
|
}
|
|
const iso = parseBahiaDateTimeInput(value)
|
|
if (!iso) throw new FormDataBoundaryError(field, 'Data e horários inválidos.')
|
|
return iso
|
|
}
|
|
|
|
const parseTasksFormData = (formData: FormData): ParsedActivityTask[] => {
|
|
const raw = boundedJsonFormValue(formData, 'tasksJson', 20_000)
|
|
if (raw === undefined) return []
|
|
if (!Array.isArray(raw)) {
|
|
throw new FormDataBoundaryError('tasksJson', 'Lista de tarefas inválida.')
|
|
}
|
|
|
|
return raw.map((item, index) => {
|
|
if (typeof item !== 'object' || item === null) {
|
|
throw new FormDataBoundaryError('tasksJson', `Tarefa ${index + 1} inválida.`)
|
|
}
|
|
const record = item as Record<string, unknown>
|
|
const title = typeof record.title === 'string' ? record.title.trim() : ''
|
|
if (!title) {
|
|
throw new FormDataBoundaryError('tasksJson', `Informe o título da tarefa ${index + 1}.`)
|
|
}
|
|
if (title.length > 200) {
|
|
throw new FormDataBoundaryError('tasksJson', `O título da tarefa ${index + 1} é muito longo.`)
|
|
}
|
|
const responsible =
|
|
typeof record.responsible === 'number' &&
|
|
Number.isInteger(record.responsible) &&
|
|
record.responsible > 0
|
|
? record.responsible
|
|
: undefined
|
|
const done = typeof record.done === 'boolean' ? record.done : undefined
|
|
|
|
return {
|
|
title,
|
|
...(responsible ? { responsible } : {}),
|
|
...(done !== undefined ? { done } : {}),
|
|
}
|
|
})
|
|
}
|
|
|
|
const parseDemandDraftsFormData = (formData: FormData): ActivityDemandDraft[] => {
|
|
const raw = boundedJsonFormValue(formData, 'demandsJson', 80_000)
|
|
if (raw === undefined) return []
|
|
const parsed = activityDemandDraftsSchema.safeParse(raw)
|
|
if (parsed.success) return parsed.data
|
|
|
|
const demandIndex = parsed.error.issues[0]?.path[0]
|
|
if (typeof demandIndex === 'number') {
|
|
throw new FormDataBoundaryError('demandsJson', `Demanda ${demandIndex + 1} inválida.`)
|
|
}
|
|
if (!Array.isArray(raw) || raw.length > MAX_ACTIVITY_DEMAND_DRAFTS) {
|
|
throw new FormDataBoundaryError(
|
|
'demandsJson',
|
|
`Informe no máximo ${MAX_ACTIVITY_DEMAND_DRAFTS} demandas válidas.`,
|
|
)
|
|
}
|
|
throw new FormDataBoundaryError('demandsJson', 'Lista de demandas inválida.')
|
|
}
|
|
|
|
const parseTagsFormData = (formData: FormData): string[] => {
|
|
const raw = boundedJsonFormValue(formData, 'tagsJson', 4_000)
|
|
if (raw === undefined) return []
|
|
if (!Array.isArray(raw)) {
|
|
throw new FormDataBoundaryError('tagsJson', 'Lista de tags inválida.')
|
|
}
|
|
const tags = raw
|
|
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
|
.filter((tag) => tag.length > 0)
|
|
.map((tag) => tag.slice(0, MAX_ACTIVITY_TAG_LENGTH))
|
|
.slice(0, MAX_ACTIVITY_TAGS)
|
|
return tags
|
|
}
|
|
|
|
type ParsedResponsible = {
|
|
relationTo: ActivityResponsibleCollection
|
|
value: number
|
|
}
|
|
|
|
/**
|
|
* C90 — the polymorphic multi-value `responsible` submits as a bounded JSON
|
|
* field (same precedent as `tasksJson`/`tagsJson`): repeated hidden inputs
|
|
* would lose the `relationTo` correlation.
|
|
*/
|
|
const parseResponsiblesFormData = (formData: FormData): ParsedResponsible[] => {
|
|
const raw = boundedJsonFormValue(formData, 'responsiblesJson', 8_000)
|
|
if (raw === undefined) return []
|
|
if (!Array.isArray(raw)) {
|
|
throw new FormDataBoundaryError('responsiblesJson', 'Lista de responsáveis inválida.')
|
|
}
|
|
if (raw.length > MAX_ACTIVITY_RESPONSIBLES) {
|
|
throw new FormDataBoundaryError(
|
|
'responsiblesJson',
|
|
`Informe no máximo ${MAX_ACTIVITY_RESPONSIBLES} responsáveis.`,
|
|
)
|
|
}
|
|
return raw.map((item, index) => {
|
|
const parsed = activityResponsibleSchema.safeParse(item)
|
|
if (!parsed.success) {
|
|
throw new FormDataBoundaryError(
|
|
'responsiblesJson',
|
|
`Responsável ${index + 1} inválido. Atualize a página e refaça a escolha.`,
|
|
)
|
|
}
|
|
return parsed.data
|
|
})
|
|
}
|
|
|
|
const parseSharedActivityFormData = (formData: FormData) => {
|
|
const allDay = checkboxFormValue(formData, 'allDay')
|
|
return {
|
|
title: optionalFormText(formData, 'title') ?? '',
|
|
tags: parseTagsFormData(formData),
|
|
status: optionalFormText(formData, 'status') as (typeof activityStatuses)[number] | undefined,
|
|
description: optionalFormText(formData, 'description'),
|
|
deputyPresent: checkboxFormValue(formData, 'deputyPresent'),
|
|
allDay,
|
|
publicEvent: checkboxFormValue(formData, 'publicEvent'),
|
|
startAt: parseScheduleFormField(formData, 'startAt', allDay),
|
|
endAt: parseScheduleFormField(formData, 'endAt', allDay),
|
|
// C165 — empty input means "Sem município" (`null`), not a missing field:
|
|
// the overlay always renders the hidden input.
|
|
municipality: nullableRelationshipFormValue(formData, 'municipality'),
|
|
locality: optionalFormText(formData, 'locality'),
|
|
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
|
|
responsible: parseResponsiblesFormData(formData),
|
|
tasks: parseTasksFormData(formData),
|
|
demands: parseDemandDraftsFormData(formData),
|
|
}
|
|
}
|
|
|
|
export type ParsedActivityCreateFormData = ActivityCreateInput & {
|
|
demands: ActivityDemandDraft[]
|
|
}
|
|
|
|
export const parseActivityCreateFormData = (formData: FormData): ParsedActivityCreateFormData => {
|
|
const shared = parseSharedActivityFormData(formData)
|
|
|
|
return {
|
|
...shared,
|
|
status: (shared.status ?? 'confirmado') as ActivityCreateInput['status'],
|
|
responsible: shared.responsible.length ? shared.responsible : undefined,
|
|
organizations: shared.organizations.length ? shared.organizations : undefined,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* E13 — the composer submits its stops as one bounded JSON field, the precedent
|
|
* `tasksJson`/`demandsJson` already set: the alternative is per-stop field names
|
|
* whose correlation has to be reconstructed from `FormData` ordering.
|
|
*
|
|
* The stop shape itself is a zod schema picked from the activity fields, so this
|
|
* boundary only has to decide the ONE thing zod cannot: which message the mesa
|
|
* sees. Titles are not parsed here — the action composes them from the município
|
|
* names it reads for the scope check.
|
|
*/
|
|
export type ParsedTourDraftFormData = {
|
|
tourName: string
|
|
note: string | undefined
|
|
stops: TourStopDraft[]
|
|
}
|
|
|
|
export const parseTourDraftFormData = (formData: FormData): ParsedTourDraftFormData => {
|
|
const tourName = requiredFormText(formData, 'tourName')
|
|
if (tourName.length > MAX_TOUR_NAME_LENGTH) {
|
|
throw new FormDataBoundaryError('tourName', 'Nome do giro muito longo.')
|
|
}
|
|
|
|
const parsed = tourStopDraftsSchema.safeParse(boundedJsonFormValue(formData, 'stopsJson', 20_000))
|
|
if (!parsed.success) {
|
|
throw new FormDataBoundaryError(
|
|
'stopsJson',
|
|
'Paradas do giro inválidas. Atualize a página e monte o giro novamente.',
|
|
)
|
|
}
|
|
if (parsed.data.length === 0) throw new FormDataBoundaryError('stopsJson', TOUR_EMPTY_MESSAGE)
|
|
|
|
return { tourName, note: optionalFormText(formData, 'note'), stops: parsed.data }
|
|
}
|
|
|
|
export type ParsedActivityUpdateFormData = ActivityUpdateInput & {
|
|
demands: ActivityDemandDraft[]
|
|
}
|
|
|
|
export const parseActivityUpdateFormData = (formData: FormData): ParsedActivityUpdateFormData => {
|
|
const shared = parseSharedActivityFormData(formData)
|
|
|
|
return {
|
|
...shared,
|
|
id: requiredRelationshipFormValue(formData, 'id'),
|
|
status: shared.status as ActivityUpdateInput['status'],
|
|
locality: shared.locality ?? null,
|
|
description: shared.description ?? null,
|
|
startAt: shared.startAt ?? null,
|
|
endAt: shared.endAt ?? null,
|
|
organizations: shared.organizations,
|
|
// Update always submits the full list (possibly empty) so clearing works.
|
|
// Invariant: the form always renders `responsiblesJson` (even empty) — a
|
|
// partial form that omits it would silently clear `responsible`.
|
|
responsible: shared.responsible,
|
|
}
|
|
}
|