teqo/src/lib/schemas/activity.ts

299 lines
11 KiB
TypeScript

import { z } from 'zod'
import { allDayRangeValid } from '@/lib/activityAllDay'
import { relationshipId } from '@/lib/relationship'
import { CAMPAIGN_DEMAND_BODY_MAX_LENGTH, campaignDemandKinds } from '@/lib/schemas/campaignDemand'
import {
positiveRelationshipId,
trimmedNullableText,
trimmedOptionalText,
} from '@/lib/schemas/primitives'
/**
* Refusal messages matched by exact string in the routes' `safeMessages` —
* named once (B32+/B37 contract): a reworded literal at either end silently
* collapses the refusal into the generic error.
*/
export const ACTIVITY_TASK_NOT_FOUND_MESSAGE = 'Tarefa não encontrada.'
export const ACTIVITY_UPDATE_BODY_REQUIRED_MESSAGE = 'Informe o texto da atualização.'
export const ACTIVITY_UPDATE_BODY_TOO_LONG_MESSAGE =
'Atualização muito longa. Reduza o texto e tente novamente.'
export const ACTIVITY_RESULT_REQUIRED_MESSAGE = 'Informe o resultado da atividade.'
export const ACTIVITY_RESULT_TOO_LONG_MESSAGE =
'Resultado muito longo. Reduza o texto e tente novamente.'
export const ACTIVITY_RESULT_STAFF_MESSAGE =
'Apenas a equipe da campanha pode registrar o resultado da atividade.'
export const ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE =
'Apenas o Coordenador Geral ou o Candidato podem remarcar compromisso com deputado presente.'
export const ACTIVITY_RESCHEDULE_FAILED_MESSAGE =
'Não foi possível remarcar a atividade. O horário anterior foi mantido.'
export const ACTIVITY_OUT_OF_SCOPE_MESSAGE =
'O município escolhido está fora do seu escopo de edição. Atualize a página e tente novamente.'
/**
* C14 — tags are free-form labels the mesa invents (comício, imprensa, etc.).
* No rigid taxonomy; the filter is the value. Max 20 tags, 80 chars each.
*/
export const MAX_ACTIVITY_TAGS = 20
export const MAX_ACTIVITY_TAG_LENGTH = 80
export const ACTIVITY_AGENDA_MAX_RANGE_DAYS = 45
export const activityStatuses = ['confirmado', 'realizado', 'cancelado'] as const
export type ActivityStatus = (typeof activityStatuses)[number]
export const activityStatusLabels: Record<ActivityStatus, string> = {
confirmado: 'Confirmado',
realizado: 'Realizado',
cancelado: 'Cancelado',
}
const MAX_ACTIVITY_ORGANIZATIONS = 20
export const MAX_ACTIVITY_DEMAND_DRAFTS = 20
/** C90 — cap for the polymorphic multi-value `responsible` field. */
export const MAX_ACTIVITY_RESPONSIBLES = 20
/**
* Draft demand born with an activity (C90). Kept as the legacy title + free
* text shape: the activity vertical is out of B195 scope, and its draft
* titles are staff-curated short names, not AI-derived ones.
*/
const activityDemandDraftSchema = z.object({
title: z.string().trim().min(2).max(160),
kind: z.enum(campaignDemandKinds),
description: trimmedOptionalText(CAMPAIGN_DEMAND_BODY_MAX_LENGTH),
})
export const activityDemandDraftsSchema = z
.array(activityDemandDraftSchema)
.max(MAX_ACTIVITY_DEMAND_DRAFTS)
export type ActivityDemandDraft = z.input<typeof activityDemandDraftSchema>
const taskSchema = z.object({
title: z.string().trim().min(1).max(200),
responsible: positiveRelationshipId.optional(),
done: z.boolean().optional(),
})
const activityTagsSchema = z
.array(z.string().trim().min(1).max(MAX_ACTIVITY_TAG_LENGTH))
.max(MAX_ACTIVITY_TAGS)
const activityResponsibleCollections = ['campaignUser', 'leadership', 'stateDeputy'] as const
export type ActivityResponsibleCollection = (typeof activityResponsibleCollections)[number]
export const activityResponsibleTypeLabels: Record<ActivityResponsibleCollection, string> = {
campaignUser: 'Equipe',
leadership: 'Liderança',
stateDeputy: 'Dobradinha',
}
export type ActivityResponsibleEntry = {
relationTo: ActivityResponsibleCollection
value: number
}
/**
* Normalizes a polymorphic `responsible` value (ids or `{relationTo, value}`
* objects at any depth) into typed entries, dropping malformed rows. Shared by
* the collection validation, the notification recipients and the view models —
* one place to add a future responsible kind.
*/
export const parseActivityResponsibleEntries = (value: unknown): ActivityResponsibleEntry[] =>
(Array.isArray(value) ? value : [])
.map((entry): ActivityResponsibleEntry | null => {
if (typeof entry !== 'object' || entry === null) return null
const record = entry as { relationTo?: unknown; value?: unknown }
const relationTo = activityResponsibleCollections.find(
(candidate) => candidate === record.relationTo,
)
if (!relationTo) return null
const id = relationshipId(record.value)
return id === null ? null : { relationTo, value: id }
})
.filter((entry): entry is ActivityResponsibleEntry => entry !== null)
/**
* C90 — one typed responsible entry. `value` must be a positive id and the
* `relationTo` one of the allowed collections; the collection hook re-verifies
* the entity exists / stays eligible.
*/
export const activityResponsibleSchema = z.object({
relationTo: z.enum(activityResponsibleCollections),
value: positiveRelationshipId,
})
const activityResponsiblesSchema = z.array(activityResponsibleSchema).max(MAX_ACTIVITY_RESPONSIBLES)
const activityFieldsSchema = z.object({
title: z.string().trim().min(2).max(160),
tags: activityTagsSchema.optional(),
status: z.enum(activityStatuses).optional(),
description: trimmedOptionalText(4000),
deputyPresent: z.boolean().optional(),
allDay: z.boolean().optional(),
startAt: z.string().datetime().optional().nullable(),
endAt: z.string().datetime().optional().nullable(),
municipality: positiveRelationshipId,
locality: trimmedOptionalText(160),
organizations: z.array(positiveRelationshipId).max(MAX_ACTIVITY_ORGANIZATIONS).optional(),
responsible: activityResponsiblesSchema.optional(),
tasks: z.array(taskSchema).optional(),
})
const validateSchedule = (
data: {
status?: ActivityStatus | null
allDay?: boolean | null
startAt?: string | null
endAt?: string | null
},
context: z.RefinementCtx,
requireStart: boolean,
) => {
if (requireStart && !data.startAt) {
context.addIssue({
code: 'custom',
message: data.allDay
? 'Informe a data de início do compromisso.'
: 'Informe a data e horário de início do compromisso.',
path: ['startAt'],
})
}
if (data.startAt && data.endAt) {
const start = new Date(data.startAt)
const end = new Date(data.endAt)
const valid = !Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())
if (valid && data.allDay) {
// C104 — all-day ranges are day granularity: the end date may equal the
// start date (single-day commitment), never precede it.
if (!allDayRangeValid(data.startAt, data.endAt)) {
context.addIssue({
code: 'custom',
message: 'A data de término deve ser igual ou posterior à de início.',
path: ['endAt'],
})
}
} else if (!valid || !(start < end)) {
context.addIssue({
code: 'custom',
message: 'O horário de término deve ser posterior ao de início.',
path: ['endAt'],
})
}
}
}
const isoInstantSchema = z.string().datetime({ offset: true })
export const activityAgendaRequestSchema = z
.object({
rangeStart: isoInstantSchema,
rangeEnd: isoInstantSchema,
municipality: positiveRelationshipId.optional(),
deputyPresent: z.literal(true).optional(),
tag: z.string().trim().min(1).max(MAX_ACTIVITY_TAG_LENGTH).optional(),
})
.superRefine((data, context) => {
const start = new Date(data.rangeStart)
const end = new Date(data.rangeEnd)
const duration = end.getTime() - start.getTime()
const maximumDuration = ACTIVITY_AGENDA_MAX_RANGE_DAYS * 86_400_000
if (!(duration > 0) || duration > maximumDuration) {
context.addIssue({
code: 'custom',
message: `Consulte no máximo ${ACTIVITY_AGENDA_MAX_RANGE_DAYS} dias da agenda por vez.`,
path: ['rangeEnd'],
})
}
})
.transform((data) => ({
...data,
rangeStart: new Date(data.rangeStart).toISOString(),
rangeEnd: new Date(data.rangeEnd).toISOString(),
}))
export const activityRescheduleSchema = z
.object({
id: positiveRelationshipId,
// C104 — the agenda knows the event's all-day state; the client converts
// FullCalendar's exclusive end into the stored inclusive convention and
// sends ready instants. allDay only relaxes the ordering rule here.
allDay: z.boolean(),
startAt: isoInstantSchema,
endAt: isoInstantSchema.nullable(),
})
.superRefine((data, context) => {
if (data.allDay) {
if (!data.endAt) {
context.addIssue({
code: 'custom',
message: 'Informe a data de término do compromisso de dia inteiro.',
path: ['endAt'],
})
return
}
if (!allDayRangeValid(data.startAt, data.endAt)) {
context.addIssue({
code: 'custom',
message: 'A data de término deve ser igual ou posterior à de início.',
path: ['endAt'],
})
}
return
}
validateSchedule(data, context, true)
})
.transform((data) => ({
...data,
startAt: new Date(data.startAt).toISOString(),
endAt: data.endAt ? new Date(data.endAt).toISOString() : null,
}))
export const activityCreateSchema = activityFieldsSchema
.extend({
status: z.enum(['confirmado']).default('confirmado'),
})
.superRefine((data, context) => {
validateSchedule(data, context, true)
})
.transform((data) => ({
...data,
endAt: data.endAt ?? undefined,
tags: data.tags ?? [],
}))
export const activityUpdateSchema = activityFieldsSchema
.partial()
.extend({
id: positiveRelationshipId,
locality: trimmedNullableText(160),
description: trimmedNullableText(4000),
startAt: z.string().datetime().nullable().optional(),
endAt: z.string().datetime().nullable().optional(),
status: z.enum(activityStatuses).optional(),
})
.superRefine((data, context) => {
validateSchedule(data, context, true)
})
export type ActivityCreateInput = z.input<typeof activityCreateSchema>
export type ActivityUpdateInput = z.input<typeof activityUpdateSchema>
export type ActivityAgendaRequest = z.input<typeof activityAgendaRequestSchema>
export type ActivityAgendaRequestData = z.output<typeof activityAgendaRequestSchema>
export type ActivityRescheduleInput = z.input<typeof activityRescheduleSchema>
/**
* E13 — one stop of a giro, as the composer submits it: which município and
* what tags the stop generates. The title is deliberately NOT part of it: it
* is composed on the server from the município's own name, so a client cannot
* label a draft after a município it did not pick.
*/
const tourStopDraftSchema = activityFieldsSchema.pick({ municipality: true, tags: true })
export const tourStopDraftsSchema = z.array(tourStopDraftSchema)
export type TourStopDraft = z.infer<typeof tourStopDraftSchema>