mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
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.
201 lines
6.4 KiB
TypeScript
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>
|