/** * @file validators/publications.ts * @description Zod schemas for server AND client-side validation of publications. * Schemas are used in the API Route (server) and the PublicationLayout (client). */ import { z } from 'zod'; // ───────────────────────────────────────────── // HELPERS // ───────────────────────────────────────────── /** Regex para validar slugs URL-friendly */ const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; // ───────────────────────────────────────────── // SCHEMAS // ───────────────────────────────────────────── /** Schema de validación principal para crear/actualizar un Post */ export const PostSchema = z.object({ titulo: z .string() .min(5, 'El título debe tener al menos 5 caracteres.') .max(250, 'El título no puede superar los 250 caracteres.'), slug: z .string() .min(3, 'El slug debe tener al menos 3 caracteres.') .regex(slugRegex, 'El slug solo puede tener letras minúsculas, números y guiones.'), contenido: z .string() .min(10, 'El contenido debe tener al menos 10 caracteres.'), estado: z.enum(['borrador', 'pendiente', 'publicado', 'archivado'], { message: 'Estado inválido.', }), fecha_publicacion: z .string() .refine((val) => !isNaN(Date.parse(val)), { message: 'La fecha de publicación no es válida.' }), tipo_publicacion_id: z .string() .min(1, 'Debe seleccionar un tipo de publicación.'), // Campos opcionales resumen_redes: z.string().max(500, 'El resumen no puede superar 500 caracteres.').optional(), fecha_finalizacion: z.string().optional().nullable(), cliente_id: z.string().optional().nullable(), imagen_destacada_id: z.string().optional().nullable(), url_destino: z.string().url('La URL de destino no es válida.').optional().or(z.literal('')), video_url: z.string().url('La URL de video no es válida.').optional().or(z.literal('')), og_image_url: z.string().url('La OG Image URL no es válida.').optional().or(z.literal('')), seo_titulo: z.string().max(70, 'El SEO título no puede superar 70 caracteres.').optional(), seo_descripcion: z.string().max(160, 'La SEO descripción no puede superar 160 caracteres.').optional(), }); /** Schema para creación rápida de relaciones (QuickCreateModal) */ export const QuickCreateSchema = z.object({ collection: z.string().min(1, 'La colección es requerida.'), data: z.record(z.string(), z.unknown()), }); /** Schema para actualización parcial (PATCH) */ export const PostPatchSchema = PostSchema.partial().extend({ $id: z.string().min(1, 'El ID del documento es requerido para actualizar.'), }); // ───────────────────────────────────────────── // INFERRED TYPES // ───────────────────────────────────────────── /** Tipo inferido del schema principal */ export type PostFormData = z.infer; /** Tipo inferido del schema de actualización parcial */ export type PostPatchData = z.infer; /** Tipo inferido del schema de creación rápida */ export type QuickCreateData = z.infer; // ───────────────────────────────────────────── // VALIDATION HELPERS // ───────────────────────────────────────────── /** * Valida un FormData de navegador contra el PostSchema. * Retorna `{ success: true, data }` o `{ success: false, errors }`. */ export function validatePostFormData(formData: FormData) { const raw = Object.fromEntries(formData.entries()); return PostSchema.safeParse(raw); } /** * Genera un slug a partir de un título. */ export function generateSlug(titulo: string): string { return titulo .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') // Quita acentos .replace(/[^a-z0-9\s-]/g, '') // Quita caracteres especiales .trim() .replace(/\s+/g, '-') // Espacios a guiones .replace(/-+/g, '-') // Múltiples guiones a uno .substring(0, 100); // Máximo 100 chars }