almeira-web/src/lib/validators/publications.ts

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
}