From 9812d70c71c2a9421241fb2def8d837afdf74a03 Mon Sep 17 00:00:00 2001 From: "Carlos (Almeira)" Date: Wed, 15 Apr 2026 10:19:56 -0500 Subject: [PATCH] fix(forms): resolve publication state enum mismatch and entity form action issues --- src/app/actions/entities.ts | 95 +++++++++++-------- src/app/actions/entity.ts | 45 +++++---- src/app/api/publications/route.ts | 2 +- src/app/dashboard/entity/page.tsx | 10 +- src/components/publications/PostCard.tsx | 10 +- .../publications/PublicationsList.tsx | 14 +-- src/lib/schema.ts | 2 +- src/lib/validators/publications.ts | 2 +- src/types/publications.ts | 2 +- 9 files changed, 110 insertions(+), 72 deletions(-) diff --git a/src/app/actions/entities.ts b/src/app/actions/entities.ts index e406fe1..4debc19 100644 --- a/src/app/actions/entities.ts +++ b/src/app/actions/entities.ts @@ -8,42 +8,75 @@ import { z } from 'zod'; import DOMPurify from 'isomorphic-dompurify'; // Re-usable rich text validator -const safeHtmlSchema = z.string().transform(html => +const safeHtmlSchema = z.string().transform(html => DOMPurify.sanitize(html, { ADD_TAGS: ['iframe'], ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'] }) ); +/** + * Extrae y limpia los datos del FormData usando el SCHEMA_MAP como whitelist. + * - Solo incluye campos definidos en el schema (evita enviar autor_id, $ACTION_*, etc.) + * - Convierte tipos (number, boolean) + * - Omite strings vacíos en campos opcionales (evita errores de validación en Appwrite) + */ +function extractCleanData(entity: string, formData: FormData): Record { + const fields = SCHEMA_MAP[entity] || []; + const allowedKeys = new Set(fields.map(f => f.name)); + const requiredKeys = new Set(fields.filter(f => f.required).map(f => f.name)); + const numericKeys = new Set(fields.filter(f => f.type === 'number').map(f => f.name)); + const booleanKeys = new Set(fields.filter(f => f.type === 'boolean').map(f => f.name)); + + const data: Record = {}; + + for (const [key, value] of formData.entries()) { + // Ignorar claves internas de Next.js y Appwrite + if (key.startsWith('$')) continue; + + // Solo campos definidos en el schema (whitelist) + if (!allowedKeys.has(key)) continue; + + const strVal = value as string; + + // Checkboxes → boolean + if (booleanKeys.has(key)) { + data[key] = strVal === 'true' || strVal === 'on'; + continue; + } + + // Números + if (numericKeys.has(key)) { + if (strVal !== '') data[key] = parseFloat(strVal); + continue; + } + + // Omitir strings vacíos en campos opcionales para evitar errores de Appwrite + if (strVal === '' && !requiredKeys.has(key)) continue; + + data[key] = strVal; + } + + return data; +} + /** * Centralized action to create a new entity document */ export async function saveEntityAction(entity: string, formData: FormData) { const collectionId = collectionMap[entity]; - const fields = SCHEMA_MAP[entity] || []; - if (!collectionId) throw new Error(`Colección no encontrada para ${entity}`); - const data: Record = {}; - for (const [key, value] of formData.entries()) { - if (key.startsWith('$ACTION')) continue; - data[key] = value; - } + const data = extractCleanData(entity, formData); - // Type conversions based on schema - const numericFields = fields.filter(f => f.type === 'number').map(f => f.name); - for (const nKey of numericFields) { - if (data[nKey]) data[nKey] = parseFloat(data[nKey]); - } - - // Normalization - if (data.estado && typeof data.estado === 'string') { + // Para posts: normalizar estado a minúsculas + if (entity === 'posts' && data.estado && typeof data.estado === 'string') { data.estado = data.estado.toLowerCase(); } - // Zod XSS Sanitization & Strict Types + // Sanitización XSS del contenido enriquecido if (entity === 'posts' && data.contenido) { - data.contenido = safeHtmlSchema.parse(data.contenido); + data.contenido = safeHtmlSchema.parse(data.contenido); } const res = await createDocumentServer(collectionId, data); @@ -51,7 +84,7 @@ export async function saveEntityAction(entity: string, formData: FormData) { revalidatePath(`/dashboard/${entity}`); redirect(`/dashboard/${entity}`); } else { - throw new Error(res.error || "Error desconocido al crear documento"); + throw new Error(res.error || 'Error desconocido al crear documento'); } } @@ -60,30 +93,18 @@ export async function saveEntityAction(entity: string, formData: FormData) { */ export async function updateEntityAction(entity: string, id: string, formData: FormData) { const collectionId = collectionMap[entity]; - const fields = SCHEMA_MAP[entity] || []; - if (!collectionId) throw new Error(`Colección no encontrada para ${entity}`); - const data: Record = {}; - for (const [key, value] of formData.entries()) { - if (key.startsWith('$ACTION')) continue; - data[key] = value; - } + const data = extractCleanData(entity, formData); - // Type conversions based on schema - const numericFields = fields.filter(f => f.type === 'number').map(f => f.name); - for (const nKey of numericFields) { - if (data[nKey]) data[nKey] = parseFloat(data[nKey]); - } - - // Normalization - if (data.estado && typeof data.estado === 'string') { + // Para posts: normalizar estado a minúsculas + if (entity === 'posts' && data.estado && typeof data.estado === 'string') { data.estado = data.estado.toLowerCase(); } - // Zod XSS Sanitization & Strict Types + // Sanitización XSS del contenido enriquecido if (entity === 'posts' && data.contenido) { - data.contenido = safeHtmlSchema.parse(data.contenido); + data.contenido = safeHtmlSchema.parse(data.contenido); } const res = await updateDocumentServer(collectionId, id, data); @@ -91,6 +112,6 @@ export async function updateEntityAction(entity: string, id: string, formData: F revalidatePath(`/dashboard/${entity}`); redirect(`/dashboard/${entity}`); } else { - throw new Error(res.error || "Error desconocido al actualizar documento"); + throw new Error(res.error || 'Error desconocido al actualizar documento'); } } diff --git a/src/app/actions/entity.ts b/src/app/actions/entity.ts index a717e73..e54612f 100644 --- a/src/app/actions/entity.ts +++ b/src/app/actions/entity.ts @@ -48,24 +48,29 @@ export async function updateEntityConfig(data: any) { if (typeof FormData !== 'undefined' && data instanceof FormData) { parsedData = {}; for (const [key, value] of Array.from(data.entries())) { - if (key !== '$id' && key !== '$collectionId' && key !== '$databaseId') { - if (typeof value === 'object' && 'size' in value && 'name' in value) { - const file = value as File; - if (file.size > 0) { - try { - const { uploadMediaServer } = await import('@/app/actions/appwrite'); - const tempForm = new FormData(); - tempForm.append('files', file); - const uploadRes = await uploadMediaServer(tempForm); - if (uploadRes.success && uploadRes.data && uploadRes.data[0]) { - const m = uploadRes.data[0]; - parsedData[key] = `${ENDPOINT}/storage/buckets/${m.bucketId || 'media'}/files/${m.$id}/view?project=${PROJECT_ID}`; - } - } catch (uploadErr) { - console.error('Error uploading file for entity config:', uploadErr); + // Ignorar claves internas de Next.js y Appwrite + if (key.startsWith('$ACTION') || key === '$id' || key === '$collectionId' || key === '$databaseId') { + continue; + } + if (typeof value === 'object' && 'size' in value && 'name' in value) { + const file = value as File; + if (file.size > 0) { + try { + const { uploadMediaServer } = await import('@/app/actions/appwrite'); + const tempForm = new FormData(); + tempForm.append('files', file); + const uploadRes = await uploadMediaServer(tempForm); + if (uploadRes.success && uploadRes.data && uploadRes.data[0]) { + const m = uploadRes.data[0]; + parsedData[key] = `${ENDPOINT}/storage/buckets/${m.bucketId || 'media'}/files/${m.$id}/view?project=${PROJECT_ID}`; } + } catch (uploadErr) { + console.error('Error uploading file for entity config:', uploadErr); } - } else { + } + } else { + // Solo incluir el campo si tiene valor real (evita errores en Appwrite con campos vacíos opcionales) + if (value !== '' && value !== null && value !== undefined) { parsedData[key] = value; } } @@ -123,9 +128,13 @@ export async function updateEntityConfig(data: any) { } } } - } catch (error) { + } catch (error: any) { console.error('Error updating entity config:', error); - throw error; + // Devolver un objeto de error estructurado en lugar de relanzar para evitar crash de la página + return { + success: false, + error: error?.message || 'Error desconocido al guardar la configuración de la entidad.' + }; } } diff --git a/src/app/api/publications/route.ts b/src/app/api/publications/route.ts index f41bf0d..e6b1137 100644 --- a/src/app/api/publications/route.ts +++ b/src/app/api/publications/route.ts @@ -74,7 +74,7 @@ function requireRole(user: any, allowedRoles: string[]): boolean { export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); - const estado = searchParams.get('estado') || 'Publicado'; + const estado = searchParams.get('estado') || 'publicado'; const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100); const offset = parseInt(searchParams.get('offset') || '0'); diff --git a/src/app/dashboard/entity/page.tsx b/src/app/dashboard/entity/page.tsx index 91e8526..a0ce541 100644 --- a/src/app/dashboard/entity/page.tsx +++ b/src/app/dashboard/entity/page.tsx @@ -26,7 +26,15 @@ export default async function EntityPage() { async function updateEntityClient(formData: FormData) { 'use server'; - await updateEntity(formData); + const result = await updateEntity(formData); + // Si hay error en la respuesta, lo relanzamos para que el cliente lo capture + if (result && typeof result === 'object' && 'success' in result && !result.success) { + throw new Error((result as any).error || 'Error al guardar la información de la entidad.'); + } + // Revalidar la ruta de configuración después del guardado exitoso + const { revalidatePath } = await import('next/cache'); + revalidatePath('/dashboard/entity'); + revalidatePath('/'); } return ( diff --git a/src/components/publications/PostCard.tsx b/src/components/publications/PostCard.tsx index ed7a004..1a7b509 100644 --- a/src/components/publications/PostCard.tsx +++ b/src/components/publications/PostCard.tsx @@ -45,10 +45,10 @@ function resolveImageUrl( /** Maps PostStatus to a visual badge style */ const statusBadge: Record = { - Publicado: { label: 'Publicado', className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' }, - Borrador: { label: 'Borrador', className: 'bg-slate-500/10 text-slate-400 border-slate-500/30' }, - Pendiente: { label: 'Pendiente', className: 'bg-amber-500/10 text-amber-400 border-amber-500/30' }, - Archivado: { label: 'Archivado', className: 'bg-red-500/10 text-red-400 border-red-500/30' }, + publicado: { label: 'Publicado', className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' }, + borrador: { label: 'Borrador', className: 'bg-slate-500/10 text-slate-400 border-slate-500/30' }, + pendiente: { label: 'Pendiente', className: 'bg-amber-500/10 text-amber-400 border-amber-500/30' }, + archivado: { label: 'Archivado', className: 'bg-red-500/10 text-red-400 border-red-500/30' }, }; /** @@ -69,7 +69,7 @@ export const PostCard: React.FC = ({ const safeTitle = decodeHtmlEntities(post.titulo); const safeExcerpt = getPlainTextExcerpt(post.resumen_redes || post.contenido || ''); - const badge = statusBadge[post.estado] ?? statusBadge['Borrador']; + const badge = statusBadge[post.estado?.toLowerCase()] ?? statusBadge['borrador']; const fechaFormatted = new Date(post.fecha_publicacion || post.$createdAt).toLocaleDateString( 'es-CO', diff --git a/src/components/publications/PublicationsList.tsx b/src/components/publications/PublicationsList.tsx index b14aeaf..01b3583 100644 --- a/src/components/publications/PublicationsList.tsx +++ b/src/components/publications/PublicationsList.tsx @@ -112,9 +112,9 @@ export const PublicationsList: React.FC = ({ const stats = useMemo(() => ({ total: posts.length, - published: posts.filter((p) => p.estado === 'Publicado').length, - draft: posts.filter((p) => p.estado === 'Borrador').length, - pending: posts.filter((p) => p.estado === 'Pendiente').length, + published: posts.filter((p) => p.estado === 'publicado').length, + draft: posts.filter((p) => p.estado === 'borrador').length, + pending: posts.filter((p) => p.estado === 'pendiente').length, }), [posts]); // ── HANDLERS ──────────────────────────────── @@ -204,10 +204,10 @@ export const PublicationsList: React.FC = ({ focus:border-cyan-500 focus:outline-none transition-all" > - - - - + + + + diff --git a/src/lib/schema.ts b/src/lib/schema.ts index e8cb394..6895e54 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -17,7 +17,7 @@ export const SCHEMA_MAP: Record = { { name: 'titulo', label: 'Título', type: 'text', required: true, section: 'Datos Básicos', gridSize: 12 }, { name: 'slug', label: 'URL Amigable (Slug)', type: 'text', required: true, section: 'Metadatos', gridSize: 6 }, { name: 'tipo_publicacion_id', label: 'Tipo de Publicación', type: 'relation', relationCollection: 'publication_types', relationLabel: 'descripcion', required: true, section: 'Metadatos', gridSize: 6 }, - { name: 'estado', label: 'Estado', type: 'select', options: ['borrador', 'pendiente', 'publicado'], required: true, section: 'Configuración', gridSize: 4 }, + { name: 'estado', label: 'Estado', type: 'select', options: ['borrador', 'pendiente', 'publicado', 'archivado'], required: true, section: 'Configuración', gridSize: 4 }, { name: 'fecha_publicacion', label: 'Fecha de Publicación', type: 'date', required: true, section: 'Configuración', gridSize: 4 }, { name: 'fecha_finalizacion', label: 'Expiración / Caducidad', type: 'date', section: 'Configuración', gridSize: 4 }, { name: 'cliente_id', label: 'Patrocinador Cliente', type: 'relation', relationCollection: 'clients', relationLabel: 'nombre_entidad', section: 'Comercial', gridSize: 6 }, diff --git a/src/lib/validators/publications.ts b/src/lib/validators/publications.ts index ba89107..35e5da4 100644 --- a/src/lib/validators/publications.ts +++ b/src/lib/validators/publications.ts @@ -32,7 +32,7 @@ export const PostSchema = z.object({ .string() .min(10, 'El contenido debe tener al menos 10 caracteres.'), - estado: z.enum(['Borrador', 'Pendiente', 'Publicado', 'Archivado'], { + estado: z.enum(['borrador', 'pendiente', 'publicado', 'archivado'], { message: 'Estado inválido.', }), diff --git a/src/types/publications.ts b/src/types/publications.ts index 24f84da..a9c67e2 100644 --- a/src/types/publications.ts +++ b/src/types/publications.ts @@ -13,7 +13,7 @@ export type UserRole = 'admin' | 'editor' | 'viewer' | 'guest'; /** Estados posibles de una publicación */ -export type PostStatus = 'Borrador' | 'Pendiente' | 'Publicado' | 'Archivado'; +export type PostStatus = 'borrador' | 'pendiente' | 'publicado' | 'archivado'; // ───────────────────────────────────────────── // CORE ENTITIES