teqo-backup/src/lib/schemas/activity.ts
Francisco Ignácio Fontoura Solla 4806c417c8 Engineering Pass 3: consolidate patterns and lock them with guards.
Fold duplicated campanha access, loaders, list/filter/form shells, and scripts into shared modules; reorganize utilities by domain; stabilize e2e; and document prevention-first audit workflow so the same smells cannot creep back in.
2026-07-29 06:11:29 -03:00

201 lines
6.4 KiB
TypeScript

import { z } from 'zod'
import { campaignDemandCreateSchema } from '@/lib/schemas/campaignDemandInput'
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 activityKinds = [
'caminhada',
'comicio',
'carreata',
'panfletagem',
'porta_a_porta',
'reuniao_apoio',
'lancamento',
'convencao',
'ato',
'entrevista',
'producao_conteudo',
'digital',
'outro',
] as const
export type ActivityKind = (typeof activityKinds)[number]
export const activityStatuses = [
'rascunho',
'planejado',
'confirmado',
'realizado',
'cancelado',
] as const
export const activityOrigins = ['dado', 'pedido_broker', 'obrigacao_politica'] as const
export type ActivityOrigin = (typeof activityOrigins)[number]
export const activityOriginLabels: Record<ActivityOrigin, string> = {
dado: 'Baseado em dado',
pedido_broker: 'Pedido de broker',
obrigacao_politica: 'Obrigação política',
}
export const activityKindLabels: Record<ActivityKind, string> = {
caminhada: 'Caminhada',
comicio: 'Comício',
carreata: 'Carreata',
panfletagem: 'Panfletagem',
porta_a_porta: 'Porta a porta',
reuniao_apoio: 'Reunião de apoio',
lancamento: 'Lançamento',
convencao: 'Convenção',
ato: 'Ato',
entrevista: 'Entrevista',
producao_conteudo: 'Produção de conteúdo',
digital: 'Digital',
outro: 'Outro',
}
export const activityStatusLabels: Record<(typeof activityStatuses)[number], string> = {
rascunho: 'Rascunho',
planejado: 'Planejado',
confirmado: 'Confirmado',
realizado: 'Realizado',
cancelado: 'Cancelado',
}
const MAX_ACTIVITY_ORGANIZATIONS = 20
export const MAX_ACTIVITY_DEMAND_DRAFTS = 20
const activityDemandDraftSchema = campaignDemandCreateSchema.pick({
title: true,
kind: true,
description: true,
})
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(),
due: z.string().datetime().optional().nullable(),
done: z.boolean().optional(),
})
const activityFieldsSchema = z.object({
title: z.string().trim().min(2).max(160),
kind: z.enum(activityKinds),
status: z.enum(activityStatuses).optional(),
description: trimmedOptionalText(4000),
deputyPresent: z.boolean().optional(),
startAt: z.string().datetime().optional().nullable(),
endAt: z.string().datetime().optional().nullable(),
deadline: z.string().datetime().optional().nullable(),
municipality: positiveRelationshipId,
locality: trimmedOptionalText(160),
organizations: z.array(positiveRelationshipId).max(MAX_ACTIVITY_ORGANIZATIONS).optional(),
advisors: z.array(positiveRelationshipId).optional(),
responsible: positiveRelationshipId.optional(),
leadership: positiveRelationshipId.optional(),
tasks: z.array(taskSchema).optional(),
origin: z.enum(activityOrigins).optional(),
})
const validateSchedule = (
data: {
status?: (typeof activityStatuses)[number] | null
startAt?: string | null
endAt?: string | null
},
context: z.RefinementCtx,
mode: 'create' | 'patch',
) => {
const status = data.status ?? (mode === 'create' ? 'rascunho' : undefined)
if (status && status !== 'rascunho' && !data.startAt) {
context.addIssue({
code: 'custom',
message: 'Informe a data e horário de início ao planejar ou confirmar a atividade.',
path: ['startAt'],
})
}
if (data.startAt && data.endAt) {
const start = new Date(data.startAt)
const end = new Date(data.endAt)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || !(start < end)) {
context.addIssue({
code: 'custom',
message: 'O horário de término deve ser posterior ao de início.',
path: ['endAt'],
})
}
}
}
export const activityCreateSchema = activityFieldsSchema
.extend({
status: z.enum(['rascunho', 'planejado']).default('rascunho'),
})
.superRefine((data, context) => {
validateSchedule(data, context, 'create')
})
.transform((data) => ({
...data,
startAt: data.startAt ?? null,
endAt: data.endAt ?? null,
deadline: data.deadline ?? null,
}))
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(),
deadline: z.string().datetime().nullable().optional(),
responsible: positiveRelationshipId.nullable().optional(),
leadership: positiveRelationshipId.nullable().optional(),
status: z.enum(activityStatuses).optional(),
})
.superRefine((data, context) => {
validateSchedule(data, context, 'patch')
})
export type ActivityCreateInput = z.input<typeof activityCreateSchema>
export type ActivityUpdateInput = z.input<typeof activityUpdateSchema>
/**
* E13 — one stop of a giro, as the composer submits it: which município, what
* happens there, where the decision came from. 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, kind: true })
.extend({ origin: z.enum(activityOrigins) })
export const tourStopDraftsSchema = z.array(tourStopDraftSchema)
export type TourStopDraft = z.infer<typeof tourStopDraftSchema>