fix(forms): resolve publication state enum mismatch and entity form action issues
This commit is contained in:
parent
2e9310d54a
commit
9812d70c71
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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.',
|
||||
}),
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user