fix(forms): resolve publication state enum mismatch and entity form action issues

This commit is contained in:
Carlos (Almeira) 2026-04-15 10:19:56 -05:00
parent 2e9310d54a
commit 9812d70c71
9 changed files with 110 additions and 72 deletions

View File

@ -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<string, any> {
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<string, any> = {};
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<string, any> = {};
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<string, any> = {};
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');
}
}

View File

@ -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.'
};
}
}

View File

@ -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');

View File

@ -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 (

View File

@ -45,10 +45,10 @@ function resolveImageUrl(
/** Maps PostStatus to a visual badge style */
const statusBadge: Record<string, { label: string; className: string }> = {
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<PostCardProps> = ({
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',

View File

@ -112,9 +112,9 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
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<PublicationsListProps> = ({
focus:border-cyan-500 focus:outline-none transition-all"
>
<option value="all">Todos los estados</option>
<option value="Publicado">Publicados</option>
<option value="Borrador">Borradores</option>
<option value="Pendiente">Pendientes</option>
<option value="Archivado">Archivados</option>
<option value="publicado">Publicados</option>
<option value="borrador">Borradores</option>
<option value="pendiente">Pendientes</option>
<option value="archivado">Archivados</option>
</select>
</div>

View File

@ -17,7 +17,7 @@ export const SCHEMA_MAP: Record<string, FieldDefinition[]> = {
{ 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 },

View File

@ -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.',
}),

View File

@ -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