110 lines
4.6 KiB
TypeScript
110 lines
4.6 KiB
TypeScript
/**
|
|
* @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<typeof PostSchema>;
|
|
|
|
/** Tipo inferido del schema de actualización parcial */
|
|
export type PostPatchData = z.infer<typeof PostPatchSchema>;
|
|
|
|
/** Tipo inferido del schema de creación rápida */
|
|
export type QuickCreateData = z.infer<typeof QuickCreateSchema>;
|
|
|
|
// ─────────────────────────────────────────────
|
|
// 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
|
|
}
|