teqo-backup/src/lib/schemas/activity.ts
Francisco Ignácio Fontoura Solla d5b4af2d98 E13: visit eligibility checklist and the tour composer.
Five conditions per município (volume, headroom, rede, janela, encaixe) with
their reason always visible, plus a staff-only composer at
/campanha/atividades/giros that turns an âncora + satélites + semente into draft
activities in one transaction. No `visita` kind and no `tour` entity:
`deputyPresent` already marks the candidate's own agenda.
2026-07-27 21:04:04 -03:00

186 lines
5.6 KiB
TypeScript

import { z } from 'zod'
import { campaignDemandCreateSchema } from '@/lib/schemas/campaignDemandInput'
import {
positiveRelationshipId,
trimmedNullableText,
trimmedOptionalText,
} from '@/lib/schemas/primitives'
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>