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

@ -15,35 +15,68 @@ const safeHtmlSchema = z.string().transform(html =>
}) })
); );
/**
* 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 * Centralized action to create a new entity document
*/ */
export async function saveEntityAction(entity: string, formData: FormData) { export async function saveEntityAction(entity: string, formData: FormData) {
const collectionId = collectionMap[entity]; const collectionId = collectionMap[entity];
const fields = SCHEMA_MAP[entity] || [];
if (!collectionId) throw new Error(`Colección no encontrada para ${entity}`); if (!collectionId) throw new Error(`Colección no encontrada para ${entity}`);
const data: Record<string, any> = {}; const data = extractCleanData(entity, formData);
for (const [key, value] of formData.entries()) {
if (key.startsWith('$ACTION')) continue;
data[key] = value;
}
// Type conversions based on schema // Para posts: normalizar estado a minúsculas
const numericFields = fields.filter(f => f.type === 'number').map(f => f.name); if (entity === 'posts' && data.estado && typeof data.estado === 'string') {
for (const nKey of numericFields) {
if (data[nKey]) data[nKey] = parseFloat(data[nKey]);
}
// Normalization
if (data.estado && typeof data.estado === 'string') {
data.estado = data.estado.toLowerCase(); data.estado = data.estado.toLowerCase();
} }
// Zod XSS Sanitization & Strict Types // Sanitización XSS del contenido enriquecido
if (entity === 'posts' && data.contenido) { if (entity === 'posts' && data.contenido) {
data.contenido = safeHtmlSchema.parse(data.contenido); data.contenido = safeHtmlSchema.parse(data.contenido);
} }
const res = await createDocumentServer(collectionId, data); const res = await createDocumentServer(collectionId, data);
@ -51,7 +84,7 @@ export async function saveEntityAction(entity: string, formData: FormData) {
revalidatePath(`/dashboard/${entity}`); revalidatePath(`/dashboard/${entity}`);
redirect(`/dashboard/${entity}`); redirect(`/dashboard/${entity}`);
} else { } 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) { export async function updateEntityAction(entity: string, id: string, formData: FormData) {
const collectionId = collectionMap[entity]; const collectionId = collectionMap[entity];
const fields = SCHEMA_MAP[entity] || [];
if (!collectionId) throw new Error(`Colección no encontrada para ${entity}`); if (!collectionId) throw new Error(`Colección no encontrada para ${entity}`);
const data: Record<string, any> = {}; const data = extractCleanData(entity, formData);
for (const [key, value] of formData.entries()) {
if (key.startsWith('$ACTION')) continue;
data[key] = value;
}
// Type conversions based on schema // Para posts: normalizar estado a minúsculas
const numericFields = fields.filter(f => f.type === 'number').map(f => f.name); if (entity === 'posts' && data.estado && typeof data.estado === 'string') {
for (const nKey of numericFields) {
if (data[nKey]) data[nKey] = parseFloat(data[nKey]);
}
// Normalization
if (data.estado && typeof data.estado === 'string') {
data.estado = data.estado.toLowerCase(); data.estado = data.estado.toLowerCase();
} }
// Zod XSS Sanitization & Strict Types // Sanitización XSS del contenido enriquecido
if (entity === 'posts' && data.contenido) { if (entity === 'posts' && data.contenido) {
data.contenido = safeHtmlSchema.parse(data.contenido); data.contenido = safeHtmlSchema.parse(data.contenido);
} }
const res = await updateDocumentServer(collectionId, id, data); const res = await updateDocumentServer(collectionId, id, data);
@ -91,6 +112,6 @@ export async function updateEntityAction(entity: string, id: string, formData: F
revalidatePath(`/dashboard/${entity}`); revalidatePath(`/dashboard/${entity}`);
redirect(`/dashboard/${entity}`); redirect(`/dashboard/${entity}`);
} else { } 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) { if (typeof FormData !== 'undefined' && data instanceof FormData) {
parsedData = {}; parsedData = {};
for (const [key, value] of Array.from(data.entries())) { for (const [key, value] of Array.from(data.entries())) {
if (key !== '$id' && key !== '$collectionId' && key !== '$databaseId') { // Ignorar claves internas de Next.js y Appwrite
if (typeof value === 'object' && 'size' in value && 'name' in value) { if (key.startsWith('$ACTION') || key === '$id' || key === '$collectionId' || key === '$databaseId') {
const file = value as File; continue;
if (file.size > 0) { }
try { if (typeof value === 'object' && 'size' in value && 'name' in value) {
const { uploadMediaServer } = await import('@/app/actions/appwrite'); const file = value as File;
const tempForm = new FormData(); if (file.size > 0) {
tempForm.append('files', file); try {
const uploadRes = await uploadMediaServer(tempForm); const { uploadMediaServer } = await import('@/app/actions/appwrite');
if (uploadRes.success && uploadRes.data && uploadRes.data[0]) { const tempForm = new FormData();
const m = uploadRes.data[0]; tempForm.append('files', file);
parsedData[key] = `${ENDPOINT}/storage/buckets/${m.bucketId || 'media'}/files/${m.$id}/view?project=${PROJECT_ID}`; const uploadRes = await uploadMediaServer(tempForm);
} if (uploadRes.success && uploadRes.data && uploadRes.data[0]) {
} catch (uploadErr) { const m = uploadRes.data[0];
console.error('Error uploading file for entity config:', uploadErr); 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; 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); 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) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); 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 limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
const offset = parseInt(searchParams.get('offset') || '0'); const offset = parseInt(searchParams.get('offset') || '0');

View File

@ -26,7 +26,15 @@ export default async function EntityPage() {
async function updateEntityClient(formData: FormData) { async function updateEntityClient(formData: FormData) {
'use server'; '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 ( return (

View File

@ -45,10 +45,10 @@ function resolveImageUrl(
/** Maps PostStatus to a visual badge style */ /** Maps PostStatus to a visual badge style */
const statusBadge: Record<string, { label: string; className: string }> = { const statusBadge: Record<string, { label: string; className: string }> = {
Publicado: { label: 'Publicado', className: 'bg-emerald-500/10 text-emerald-400 border-emerald-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' }, 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' }, 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' }, 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 safeTitle = decodeHtmlEntities(post.titulo);
const safeExcerpt = getPlainTextExcerpt(post.resumen_redes || post.contenido || ''); 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( const fechaFormatted = new Date(post.fecha_publicacion || post.$createdAt).toLocaleDateString(
'es-CO', 'es-CO',

View File

@ -112,9 +112,9 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
const stats = useMemo(() => ({ const stats = useMemo(() => ({
total: posts.length, total: posts.length,
published: posts.filter((p) => p.estado === 'Publicado').length, published: posts.filter((p) => p.estado === 'publicado').length,
draft: posts.filter((p) => p.estado === 'Borrador').length, draft: posts.filter((p) => p.estado === 'borrador').length,
pending: posts.filter((p) => p.estado === 'Pendiente').length, pending: posts.filter((p) => p.estado === 'pendiente').length,
}), [posts]); }), [posts]);
// ── HANDLERS ──────────────────────────────── // ── HANDLERS ────────────────────────────────
@ -204,10 +204,10 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
focus:border-cyan-500 focus:outline-none transition-all" focus:border-cyan-500 focus:outline-none transition-all"
> >
<option value="all">Todos los estados</option> <option value="all">Todos los estados</option>
<option value="Publicado">Publicados</option> <option value="publicado">Publicados</option>
<option value="Borrador">Borradores</option> <option value="borrador">Borradores</option>
<option value="Pendiente">Pendientes</option> <option value="pendiente">Pendientes</option>
<option value="Archivado">Archivados</option> <option value="archivado">Archivados</option>
</select> </select>
</div> </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: '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: '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: '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_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: '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 }, { 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() .string()
.min(10, 'El contenido debe tener al menos 10 caracteres.'), .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.', message: 'Estado inválido.',
}), }),

View File

@ -13,7 +13,7 @@
export type UserRole = 'admin' | 'editor' | 'viewer' | 'guest'; export type UserRole = 'admin' | 'editor' | 'viewer' | 'guest';
/** Estados posibles de una publicación */ /** Estados posibles de una publicación */
export type PostStatus = 'Borrador' | 'Pendiente' | 'Publicado' | 'Archivado'; export type PostStatus = 'borrador' | 'pendiente' | 'publicado' | 'archivado';
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// CORE ENTITIES // CORE ENTITIES