From dda9ae0f4a109d5f81fa7fd4fd591b930118aa56 Mon Sep 17 00:00:00 2001 From: "Carlos (Almeira)" Date: Sat, 28 Mar 2026 08:47:40 -0500 Subject: [PATCH] feat: implement robust publications module with real-time validation and state management - Refactor dashboard entity pages to support dynamic publications management. - Add Publications Module with Atomic Design components (PostCard, PublicationsList, FormSection). - Implement Zod-based real-time validation for publication forms. - Manage state using Zustand for optimistic UI and in-place relations. - Add RBAC protection with PermissionGuard and server-side API validation. - Fix: Resolve TypeScript type error in AdWrappers.tsx to ensure successful build. - Add ToastProvider and ErrorBoundary for improved user experience. - Update dependencies for publications logic. --- package-lock.json | 28 +- package.json | 3 + .../api/publications/quick-create/route.ts | 61 +++ src/app/api/publications/route.ts | 223 ++++++++ src/app/dashboard/[entity]/new/page.tsx | 113 ++-- src/app/dashboard/[entity]/page.tsx | 227 +++++--- src/app/dashboard/layout.tsx | 20 +- src/components/AdWrappers.tsx | 12 +- src/components/publications/ErrorBoundary.tsx | 79 +++ src/components/publications/FormSection.tsx | 62 +++ .../publications/PermissionGuard.tsx | 53 ++ src/components/publications/PostCard.tsx | 195 +++++++ .../publications/PublicationDrawer.tsx | 168 ++++++ .../publications/PublicationLayout.tsx | 496 ++++++++++++++++++ .../publications/PublicationsList.tsx | 299 +++++++++++ .../publications/RelationSelectField.tsx | 297 +++++++++++ src/components/publications/ToastProvider.tsx | 189 +++++++ src/lib/validators/publications.ts | 109 ++++ src/store/usePublicationStore.ts | 104 ++++ src/types/publications.ts | 215 ++++++++ 20 files changed, 2846 insertions(+), 107 deletions(-) create mode 100644 src/app/api/publications/quick-create/route.ts create mode 100644 src/app/api/publications/route.ts create mode 100644 src/components/publications/ErrorBoundary.tsx create mode 100644 src/components/publications/FormSection.tsx create mode 100644 src/components/publications/PermissionGuard.tsx create mode 100644 src/components/publications/PostCard.tsx create mode 100644 src/components/publications/PublicationDrawer.tsx create mode 100644 src/components/publications/PublicationLayout.tsx create mode 100644 src/components/publications/PublicationsList.tsx create mode 100644 src/components/publications/RelationSelectField.tsx create mode 100644 src/components/publications/ToastProvider.tsx create mode 100644 src/lib/validators/publications.ts create mode 100644 src/store/usePublicationStore.ts create mode 100644 src/types/publications.ts diff --git a/package-lock.json b/package-lock.json index e3df2e2..8d4d6fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,10 +17,12 @@ "@tiptap/extension-underline": "^3.21.0", "@tiptap/react": "^3.21.0", "@tiptap/starter-kit": "^3.21.0", + "@types/dompurify": "^3.0.5", "@types/video.js": "^7.3.58", "appwrite": "^23.0.0", "axios": "^1.13.6", "clsx": "^2.1.1", + "dompurify": "^3.3.3", "lucide-react": "^0.577.0", "next": "16.1.6", "node-appwrite": "^22.1.3", @@ -28,6 +30,7 @@ "react-dom": "19.2.3", "tailwind-merge": "^3.5.0", "video.js": "^8.23.7", + "zod": "^4.3.6", "zustand": "^5.0.12" }, "devDependencies": { @@ -2086,6 +2089,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2157,6 +2169,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -3579,6 +3597,15 @@ "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -7893,7 +7920,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index f3aa84c..a900e50 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,12 @@ "@tiptap/extension-underline": "^3.21.0", "@tiptap/react": "^3.21.0", "@tiptap/starter-kit": "^3.21.0", + "@types/dompurify": "^3.0.5", "@types/video.js": "^7.3.58", "appwrite": "^23.0.0", "axios": "^1.13.6", "clsx": "^2.1.1", + "dompurify": "^3.3.3", "lucide-react": "^0.577.0", "next": "16.1.6", "node-appwrite": "^22.1.3", @@ -30,6 +32,7 @@ "react-dom": "19.2.3", "tailwind-merge": "^3.5.0", "video.js": "^8.23.7", + "zod": "^4.3.6", "zustand": "^5.0.12" }, "devDependencies": { diff --git a/src/app/api/publications/quick-create/route.ts b/src/app/api/publications/quick-create/route.ts new file mode 100644 index 0000000..a3280d2 --- /dev/null +++ b/src/app/api/publications/quick-create/route.ts @@ -0,0 +1,61 @@ +/** + * @file app/api/publications/quick-create/route.ts + * @description Quick-create API endpoint invoked by RelationSelectField's portal modal. + * Validates session and creates a new document in any allowed relation collection. + */ +import { NextRequest, NextResponse } from 'next/server'; +import { ID } from 'node-appwrite'; +import { createSessionClient } from '@/lib/appwrite.server'; +import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite'; +import { QuickCreateSchema } from '@/lib/validators/publications'; +import { collectionMap } from '@/lib/schema'; +import type { ApiResponse } from '@/types/publications'; + +const DB = APPWRITE_ALMEIRA_CONFIG.databaseId; + +/** Collections that can be created via Quick Create */ +const ALLOWED_QUICK_CREATE = ['publication_types', 'clients', 'categories', 'cities', 'departments']; + +export async function POST(request: NextRequest) { + // Auth + const session = await createSessionClient(); + if (!session) { + return NextResponse.json({ success: false, error: 'No autenticado.' }, { status: 401 }); + } + + // Parse & validate + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ success: false, error: 'Cuerpo inválido.' }, { status: 400 }); + } + + const parseResult = QuickCreateSchema.safeParse(body); + if (!parseResult.success) { + return NextResponse.json({ success: false, error: 'Datos inválidos.' }, { status: 422 }); + } + + const { collection, data } = parseResult.data; + + // Only allow whitelisted collections + if (!ALLOWED_QUICK_CREATE.includes(collection)) { + return NextResponse.json( + { success: false, error: `Creación rápida no permitida para la colección: ${collection}` }, + { status: 403 } + ); + } + + const collectionId = collectionMap[collection]; + if (!collectionId) { + return NextResponse.json({ success: false, error: 'Colección no encontrada.' }, { status: 404 }); + } + + try { + const document = await session.databases.createDocument(DB, collectionId, ID.unique(), data as Record); + return NextResponse.json({ success: true, data: document }, { status: 201 }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al crear el registro.'; + return NextResponse.json({ success: false, error: message }, { status: 500 }); + } +} diff --git a/src/app/api/publications/route.ts b/src/app/api/publications/route.ts new file mode 100644 index 0000000..cc553df --- /dev/null +++ b/src/app/api/publications/route.ts @@ -0,0 +1,223 @@ +/** + * @file app/api/publications/route.ts + * @description Secure API Route for Publications CRUD. + * + * Security layers: + * 1. Session validation via createSessionClient() + * 2. Role-based authorization (editor/admin for mutations) + * 3. Zod server-side validation before any DB write + * 4. Returns standardized ApiResponse + */ +import { NextRequest, NextResponse } from 'next/server'; +import { ID, Query } from 'node-appwrite'; +import { createSessionClient, createAdminClient } from '@/lib/appwrite.server'; +import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite'; +import { PostSchema, PostPatchSchema } from '@/lib/validators/publications'; +import type { ApiResponse } from '@/types/publications'; + +const DB = APPWRITE_ALMEIRA_CONFIG.databaseId; +const COLLECTION = 'posts'; + +// ───────────────────────────────────────────── +// HELPERS +// ───────────────────────────────────────────── + +/** Validates the session and returns the user, or throws a 401 response */ +async function requireAuth() { + const session = await createSessionClient(); + if (!session) { + return { user: null, error: NextResponse.json( + { success: false, error: 'No autenticado. Inicia sesión para continuar.' }, + { status: 401 } + )}; + } + try { + const user = await session.account.get(); + return { user, session, error: null }; + } catch { + return { user: null, error: NextResponse.json( + { success: false, error: 'Sesión inválida o expirada.' }, + { status: 401 } + )}; + } +} + +/** Checks if user has the required role */ +function requireRole(user: any, allowedRoles: string[]): boolean { + const role: string = user?.prefs?.role || 'viewer'; + return allowedRoles.includes(role); +} + +// ───────────────────────────────────────────── +// GET — List Publications (public, cached) +// ───────────────────────────────────────────── + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const estado = searchParams.get('estado') || 'Publicado'; + const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100); + const offset = parseInt(searchParams.get('offset') || '0'); + + const client = await createAdminClient(); + const response = await client.databases.listDocuments(DB, COLLECTION, [ + Query.equal('estado', estado), + Query.orderDesc('fecha_publicacion'), + Query.limit(limit), + Query.offset(offset), + ]); + + return NextResponse.json( + { success: true, data: response }, + { + status: 200, + headers: { + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300', + }, + } + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al obtener publicaciones.'; + return NextResponse.json({ success: false, error: message }, { status: 500 }); + } +} + +// ───────────────────────────────────────────── +// POST — Create Publication (editor/admin only) +// ───────────────────────────────────────────── + +export async function POST(request: NextRequest) { + // 1. Auth check + const { user, session, error: authError } = await requireAuth(); + if (authError || !user || !session) return authError; + + // 2. Role check + if (!requireRole(user, ['editor', 'admin'])) { + return NextResponse.json( + { success: false, error: 'Acceso denegado. Se requiere rol de Editor o Administrador.' }, + { status: 403 } + ); + } + + // 3. Parse body + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ success: false, error: 'Cuerpo de la solicitud inválido.' }, { status: 400 }); + } + + // 4. Zod validation + const parseResult = PostSchema.safeParse(body); + if (!parseResult.success) { + const errors: Record = {}; + parseResult.error.errors.forEach((err) => { + const key = String(err.path[0]); + if (!errors[key]) errors[key] = []; + errors[key].push(err.message); + }); + return NextResponse.json( + { success: false, error: 'Datos inválidos.', validationErrors: errors }, + { status: 422 } + ); + } + + // 5. Create document + try { + const document = await session.databases.createDocument( + DB, + COLLECTION, + ID.unique(), + parseResult.data + ); + return NextResponse.json({ success: true, data: document }, { status: 201 }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al crear la publicación.'; + return NextResponse.json({ success: false, error: message }, { status: 500 }); + } +} + +// ───────────────────────────────────────────── +// PUT — Update Publication (editor/admin only) +// ───────────────────────────────────────────── + +export async function PUT(request: NextRequest) { + // 1. Auth check + const { user, session, error: authError } = await requireAuth(); + if (authError || !user || !session) return authError; + + // 2. Role check + if (!requireRole(user, ['editor', 'admin'])) { + return NextResponse.json( + { success: false, error: 'Acceso denegado. Se requiere rol de Editor o Administrador.' }, + { status: 403 } + ); + } + + // 3. Parse body + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ success: false, error: 'Cuerpo de la solicitud inválido.' }, { status: 400 }); + } + + // 4. Zod validation (partial update schema) + const parseResult = PostPatchSchema.safeParse(body); + if (!parseResult.success) { + const errors: Record = {}; + parseResult.error.errors.forEach((err) => { + const key = String(err.path[0]); + if (!errors[key]) errors[key] = []; + errors[key].push(err.message); + }); + return NextResponse.json( + { success: false, error: 'Datos inválidos.', validationErrors: errors }, + { status: 422 } + ); + } + + const { $id, ...updateData } = parseResult.data; + + // 5. Update document + try { + const document = await session.databases.updateDocument(DB, COLLECTION, $id!, updateData); + return NextResponse.json({ success: true, data: document }, { status: 200 }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al actualizar la publicación.'; + return NextResponse.json({ success: false, error: message }, { status: 500 }); + } +} + +// ───────────────────────────────────────────── +// DELETE — Delete Publication (admin only) +// ───────────────────────────────────────────── + +export async function DELETE(request: NextRequest) { + // 1. Auth check + const { user, session, error: authError } = await requireAuth(); + if (authError || !user || !session) return authError; + + // 2. Role check — only admins can delete + if (!requireRole(user, ['admin'])) { + return NextResponse.json( + { success: false, error: 'Acceso denegado. Solo los Administradores pueden eliminar publicaciones.' }, + { status: 403 } + ); + } + + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json({ success: false, error: 'ID de publicación requerido.' }, { status: 400 }); + } + + try { + await session.databases.deleteDocument(DB, COLLECTION, id); + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al eliminar la publicación.'; + return NextResponse.json({ success: false, error: message }, { status: 500 }); + } +} diff --git a/src/app/dashboard/[entity]/new/page.tsx b/src/app/dashboard/[entity]/new/page.tsx index 5b278a1..69a25ea 100644 --- a/src/app/dashboard/[entity]/new/page.tsx +++ b/src/app/dashboard/[entity]/new/page.tsx @@ -1,57 +1,102 @@ import { APPWRITE_ALMEIRA_CONFIG, projectId, endpoint } from '@/lib/appwrite'; -import { createSessionClient } from '@/lib/appwrite.server'; +import { createAdminClient, getLoggedInUser } from '@/lib/appwrite.server'; +import { Query } from 'node-appwrite'; import { collectionMap, SCHEMA_MAP } from '@/lib/schema'; -import { createDocumentServer } from '@/app/actions/appwrite'; import { saveEntityAction } from '@/app/actions/entities'; import { redirect } from 'next/navigation'; -import { EntityForm } from '@/components/dashboard/EntityForm'; +import type { AuthUser } from '@/types/publications'; -export default async function NewEntityPage({ params }: { params: Promise<{ entity: string }> }) { +/** + * New entity page — uses the specialized PublicationLayout for 'posts', + * and the generic EntityForm for all other entity types. + */ +export default async function NewEntityPage({ + params, +}: { + params: Promise<{ entity: string }>; +}) { const { entity } = await params; - + const collectionId = collectionMap[entity]; const fields = SCHEMA_MAP[entity] || []; - const entityNames: Record = { - posts: 'Publicaciones', categories: 'Categorías', clients: 'Clientes', client_types: 'Tipos de Cliente', - countries: 'Países', departments: 'Departamentos', cities: 'Ciudades', contracts: 'Contratos', - payment_methods: 'Formas de Pago', publication_types: 'Tipos de Publicación' - }; - const entityName = entityNames[entity] || entity.replace(/_/g, ' '); - if (!collectionId || fields.length === 0) { return ( -

Configuración no encontrada para {entity}

+
+

Configuración no encontrada para: {entity}

+
); } - const sessionClient = await createSessionClient(); - if (!sessionClient) { - throw new Error("No sesión activa"); + // Auth check + const user = await getLoggedInUser(); + if (!user) redirect('/login'); + + // Pre-load all relation data server-side + const relationData: Record> = {}; + try { + const client = await createAdminClient(); + await Promise.all( + fields + .filter((f) => f.type === 'relation' && f.relationCollection) + .map(async (field) => { + const relKey = field.relationCollection as string; + const relColId = collectionMap[relKey]; + if (!relColId) return; + try { + const res = await client.databases.listDocuments( + APPWRITE_ALMEIRA_CONFIG.databaseId, + relColId, + [Query.limit(100)] + ); + relationData[field.name] = res.documents as Array<{ $id: string; [key: string]: unknown }>; + } catch { + // Silent fail for individual relations + } + }) + ); + } catch (error) { + console.error('Error loading relation data:', error); } - const relationData: Record = {}; - await Promise.all( - fields.filter(f => f.type === 'relation' && f.relationCollection).map(async (field) => { - try { - const relKey = field.relationCollection as string; - if(!collectionMap[relKey]) return; - const res = await sessionClient.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, collectionMap[relKey], []); - relationData[field.name] = res.documents; - } catch(e) { console.error('Error obteniendo relaciones de', field.name, e); } - }) - ); - const boundSave = saveEntityAction.bind(null, entity); + const appwriteConfig = { + endpoint, + projectId, + databaseId: APPWRITE_ALMEIRA_CONFIG.databaseId, + }; - return ( - + ); + } + + // ── OTHER ENTITIES: Use the generic EntityForm ──────── + const { EntityForm } = await import('@/components/dashboard/EntityForm'); + + const entityNames: Record = { + categories: 'Categorías', clients: 'Clientes', client_types: 'Tipos de Cliente', + countries: 'Países', departments: 'Departamentos', cities: 'Ciudades', + contracts: 'Contratos', payment_methods: 'Formas de Pago', publication_types: 'Tipos de Publicación', + }; + + return ( + ); } diff --git a/src/app/dashboard/[entity]/page.tsx b/src/app/dashboard/[entity]/page.tsx index c38dd12..cc68cce 100644 --- a/src/app/dashboard/[entity]/page.tsx +++ b/src/app/dashboard/[entity]/page.tsx @@ -1,34 +1,134 @@ -import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite'; -import { createSessionClient, getLoggedInUser } from '@/lib/appwrite.server'; +import { APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/appwrite'; +import { createAdminClient, createSessionClient, getLoggedInUser } from '@/lib/appwrite.server'; import { Query } from 'node-appwrite'; import Link from 'next/link'; import { collectionMap, SCHEMA_MAP } from '@/lib/schema'; import { DataTable } from '@/components/dashboard/DataTable'; import { getUserPreferences } from '@/lib/preferences'; +import { ErrorBoundary } from '@/components/publications/ErrorBoundary'; +import { saveEntityAction } from '@/app/actions/entities'; +import type { Post, AuthUser } from '@/types/publications'; -export default async function EntityPage({ +// ───────────────────────────────────────────── +// POSTS‑SPECIFIC LAYOUT (card grid + drawer) +// ───────────────────────────────────────────── + +/** + * Renders the publications module with PublicationsList (cards + drawer) + * instead of the generic DataTable, when entity === 'posts'. + */ +async function PostsPage({ + entity, + user, +}: { + entity: string; + user: AuthUser; +}) { + // Lazy import server-side only + const { PublicationsList } = await import('@/components/publications/PublicationsList'); + + const collectionId = collectionMap[entity]; + let posts: Post[] = []; + let relationData: Record> = {}; + + try { + const client = await createAdminClient(); + + // Fetch all published + draft + pending posts + const res = await client.databases.listDocuments( + APPWRITE_ALMEIRA_CONFIG.databaseId, + collectionId, + [Query.orderDesc('$createdAt'), Query.limit(100)] + ); + posts = res.documents as unknown as Post[]; + + // Pre-load relation data for the drawer/form + const fields = SCHEMA_MAP[entity] || []; + await Promise.all( + fields + .filter((f) => f.type === 'relation' && f.relationCollection) + .map(async (field) => { + const relKey = field.relationCollection as string; + const relColId = collectionMap[relKey]; + if (!relColId) return; + try { + const relRes = await client.databases.listDocuments( + APPWRITE_ALMEIRA_CONFIG.databaseId, + relColId, + [Query.limit(100)] + ); + relationData[field.name] = relRes.documents as Array<{ $id: string; [key: string]: unknown }>; + } catch { + // Fail silently for individual relation fetches + } + }) + ); + } catch (error) { + console.error('PostsPage fetch error:', error); + } + + const boundEditAction = saveEntityAction.bind(null, entity); + + return ( +
+ {/* Page Header */} +
+
+

+ Publicaciones +

+

+ {posts.length} registros en el sistema +

+
+
+ + + + +
+ ); +} + +// ───────────────────────────────────────────── +// GENERIC ENTITY PAGE (DataTable) +// ───────────────────────────────────────────── + +export default async function EntityPage({ params, - searchParams -}: { - params: Promise<{ entity: string }>, - searchParams: Promise<{ q?: string, page?: string, sort?: string, dir?: string }> + searchParams, +}: { + params: Promise<{ entity: string }>; + searchParams: Promise<{ q?: string; page?: string; sort?: string; dir?: string }>; }) { const { entity } = await params; const sParams = await searchParams; const q = sParams.q || ''; const page = Number(sParams.page) || 1; const sort = sParams.sort || '$createdAt'; - const dir = (sParams.dir === 'asc' || sParams.dir === 'desc') ? sParams.dir : 'desc'; + const dir = sParams.dir === 'asc' ? 'asc' : 'desc'; const limit = 15; const offset = (page - 1) * limit; - + const collectionId = collectionMap[entity]; const fieldsConfig = SCHEMA_MAP[entity] || []; const entityNames: Record = { - posts: 'Publicaciones', categories: 'Categorías', clients: 'Clientes', client_types: 'Tipos de Cliente', - countries: 'Países', departments: 'Departamentos', cities: 'Ciudades', contracts: 'Contratos', - payment_methods: 'Formas de Pago', publication_types: 'Tipos de Publicación' + posts: 'Publicaciones', categories: 'Categorías', clients: 'Clientes', + client_types: 'Tipos de Cliente', countries: 'Países', departments: 'Departamentos', + cities: 'Ciudades', contracts: 'Contratos', payment_methods: 'Formas de Pago', + publication_types: 'Tipos de Publicación', }; const entityName = entityNames[entity] || entity.replace(/_/g, ' '); @@ -42,48 +142,48 @@ export default async function EntityPage({ ); } - // Fetch user and preferences const user = await getLoggedInUser(); const prefs = await getUserPreferences(); const hiddenColumns = prefs.hiddenColumns?.[entity] || []; - let documents: any[] = []; + // Redirect the 'posts' entity to the specialized card view + if (entity === 'posts') { + return ; + } + + // Generic DataTable for all other entities + let documents: Array> = []; let total = 0; + try { const sessionClient = await createSessionClient(); - if (!sessionClient) { - throw new Error("No sesión activa"); - } + if (!sessionClient) throw new Error('No sesión activa'); - const queries = [ - Query.limit(limit), - Query.offset(offset), - ]; + const queries = [Query.limit(limit), Query.offset(offset)]; - // Apply Sorting if (dir === 'asc') { - queries.push(Query.orderAsc(sort)); + queries.push(Query.orderAsc(sort)); } else { - queries.push(Query.orderDesc(sort)); + queries.push(Query.orderDesc(sort)); } if (q) { - const searchableFields = fieldsConfig - .filter(f => f.type === 'text' || f.type === 'textarea') - .map(f => f.name) - .slice(0, 2); - - if (searchableFields.length > 0) { - queries.push(Query.contains(searchableFields[0], q)); - } + const searchableFields = fieldsConfig + .filter((f) => f.type === 'text' || f.type === 'textarea') + .map((f) => f.name) + .slice(0, 2); + + if (searchableFields.length > 0) { + queries.push(Query.contains(searchableFields[0], q)); + } } const response = await sessionClient.databases.listDocuments( - APPWRITE_ALMEIRA_CONFIG.databaseId, - collectionId, + APPWRITE_ALMEIRA_CONFIG.databaseId, + collectionId, queries ); - documents = response.documents; + documents = response.documents as Array>; total = response.total; } catch (error) { console.error(`Error fetching from ${collectionId}:`, error); @@ -102,24 +202,27 @@ export default async function EntityPage({
- - + +
- + + Nuevo - limit && (
- - ANTERIOR - - - Página {page} de {Math.ceil(total / limit)} - - = Math.ceil(total / limit) ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`} - > - SIGUIENTE - + + ANTERIOR + + + Página {page} de {Math.ceil(total / limit)} + + = Math.ceil(total / limit) ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`} + > + SIGUIENTE +
)} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 957fa3c..3574ad8 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,6 +1,8 @@ import { Sidebar } from '@/components/Sidebar'; import { getLoggedInUser } from '@/lib/appwrite.server'; import { redirect } from 'next/navigation'; +import { ToastProvider } from '@/components/publications/ToastProvider'; +import { ErrorBoundary } from '@/components/publications/ErrorBoundary'; export default async function DashboardLayout({ children, @@ -13,12 +15,16 @@ export default async function DashboardLayout({ } return ( -
- - {/* Main content */} -
- {children} -
-
+ +
+ + {/* Main content */} +
+ + {children} + +
+
+
); } diff --git a/src/components/AdWrappers.tsx b/src/components/AdWrappers.tsx index 1dbf185..49c3dbe 100644 --- a/src/components/AdWrappers.tsx +++ b/src/components/AdWrappers.tsx @@ -3,11 +3,17 @@ import { createAdminClient } from '@/lib/appwrite.server'; import TopBanner from './TopBanner'; import ClientSidebars from './ClientSidebars'; -async function fetchSettings() { +interface Settings { + rotation_seconds: number; + ticker_count: number; + [key: string]: any; +} + +async function fetchSettings(): Promise { try { const client = await createAdminClient(); const res = await client.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, 'settings', [Query.limit(1)]); - if (res.documents.length > 0) return res.documents[0]; + if (res.documents.length > 0) return res.documents[0] as unknown as Settings; } catch(e) { console.error("fetchSettings error:", e); } @@ -51,7 +57,7 @@ export async function GlobalTopBanner() { } export async function GlobalSidebar() { - let settings = { rotation_seconds: 5 }; + let settings = { rotation_seconds: 5 } as Settings; let groupedAds: Record = {}; try { diff --git a/src/components/publications/ErrorBoundary.tsx b/src/components/publications/ErrorBoundary.tsx new file mode 100644 index 0000000..416f44c --- /dev/null +++ b/src/components/publications/ErrorBoundary.tsx @@ -0,0 +1,79 @@ +'use client'; + +/** + * @file ErrorBoundary.tsx + * @description React Error Boundary for the Publications module. + * Catches runtime errors in any child component and displays a graceful fallback. + * + * Note: Error Boundaries must be class components per React spec. + */ + +import React, { Component, type ErrorInfo, type ReactNode } from 'react'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; + +interface Props { + children: ReactNode; + /** Custom fallback UI. Defaults to a generic error card. */ + fallback?: ReactNode; + /** Called when an error is caught — use to send to error reporting */ + onError?: (error: Error, info: ErrorInfo) => void; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error('[ErrorBoundary] Caught error:', error, info); + this.props.onError?.(error, info); + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) return this.props.fallback; + + return ( +
+
+ +
+
+

+ Algo salió mal +

+

+ {this.state.error?.message || 'Error inesperado en el módulo de publicaciones.'} +

+
+ +
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/components/publications/FormSection.tsx b/src/components/publications/FormSection.tsx new file mode 100644 index 0000000..4a834e0 --- /dev/null +++ b/src/components/publications/FormSection.tsx @@ -0,0 +1,62 @@ +'use client'; + +/** + * @file FormSection.tsx + * @description Reusable form section wrapper with title, icon, and internal grid. + * Groups related form fields visually and logically. + * + * @example + * } columns={2}> + * + * + * + */ + +import React from 'react'; +import { FormSectionProps } from '@/types/publications'; + +/** Maps column count to Tailwind grid class */ +const gridColsMap: Record = { + 1: 'grid-cols-1', + 2: 'grid-cols-1 md:grid-cols-2', + 3: 'grid-cols-1 md:grid-cols-3', + 4: 'grid-cols-2 md:grid-cols-4', + 6: 'grid-cols-2 md:grid-cols-6', + 12: 'grid-cols-12', +}; + +export const FormSection: React.FC = ({ + title, + icon, + columns = 2, + children, + className = '', +}) => { + const gridClass = gridColsMap[columns] ?? 'grid-cols-1 md:grid-cols-2'; + + return ( +
+ {/* Section Header */} +
+ {icon && ( +
+ {icon} +
+ )} +
+ + {title} + +
+
+
+ + {/* Fields Grid */} +
+ {children} +
+
+ ); +}; + +export default FormSection; diff --git a/src/components/publications/PermissionGuard.tsx b/src/components/publications/PermissionGuard.tsx new file mode 100644 index 0000000..ec6216d --- /dev/null +++ b/src/components/publications/PermissionGuard.tsx @@ -0,0 +1,53 @@ +'use client'; + +/** + * @file PermissionGuard.tsx + * @description Role-Based Access Control (RBAC) HOC/Wrapper component. + * Only renders children if the user's role is in the allowedRoles list. + * Use this to protect any UI element that requires authorization. + * + * @example + * // Only admins and editors can see the edit button + * + * + * + */ + +import React from 'react'; +import { PermissionGuardProps, UserRole } from '@/types/publications'; + +/** Priority order of roles (higher index = more permissions) */ +const ROLE_HIERARCHY: Record = { + guest: 0, + viewer: 1, + editor: 2, + admin: 3, +}; + +/** + * Checks if a given role meets the minimum required role. + */ +export function hasPermission(userRole: UserRole, allowedRoles: UserRole[]): boolean { + return allowedRoles.some( + (allowed) => ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[allowed] + ); +} + +/** + * PermissionGuard — Renders children only if userRole is in allowedRoles. + * Renders `fallback` (or null) otherwise. + */ +export const PermissionGuard: React.FC = ({ + allowedRoles, + userRole, + children, + fallback = null, +}) => { + if (!hasPermission(userRole, allowedRoles)) { + return <>{fallback}; + } + + return <>{children}; +}; + +export default PermissionGuard; diff --git a/src/components/publications/PostCard.tsx b/src/components/publications/PostCard.tsx new file mode 100644 index 0000000..781a39a --- /dev/null +++ b/src/components/publications/PostCard.tsx @@ -0,0 +1,195 @@ +'use client'; + +/** + * @file PostCard.tsx + * @description Pure presentation component for displaying a Post in the dashboard. + * + * - Receives all data via props (no internal data fetching). + * - Uses `PermissionGuard` to conditionally show edit/delete controls. + * - XSS-safe: all text content uses textContent rendering (no dangerouslySetInnerHTML). + * - The `onEdit` callback is injected by the parent — this component stays pure. + */ + +import React from 'react'; +import Link from 'next/link'; +import { Edit2, Trash2, Eye, Calendar, Tag } from 'lucide-react'; +import { Post, PostCardProps, UserRole } from '@/types/publications'; +import PermissionGuard from './PermissionGuard'; +import { decodeHtmlEntities } from '@/lib/utils'; + +/** Derives a plain text excerpt from raw HTML content — XSS safe */ +function getPlainTextExcerpt(html: string, maxLength = 120): string { + // Remove all HTML tags using regex (safe for server, no DOM parsing needed) + const plain = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + return plain.length > maxLength ? plain.substring(0, maxLength) + '…' : plain; +} + +/** Resolves the image URL from a Post's imagen_destacada_id (string or MediaItem object) */ +function resolveImageUrl( + post: Post, + endpoint: string, + projectId: string +): string | null { + if (post.og_image_url) return post.og_image_url; + + if (!post.imagen_destacada_id) return null; + + if (typeof post.imagen_destacada_id === 'object') { + const { bucket_id, file_id } = post.imagen_destacada_id; + return `${endpoint}/storage/buckets/${bucket_id}/files/${file_id}/preview?project=${projectId}&width=600&height=400&quality=80`; + } + + // Fallback: just an ID, use default 'media' bucket + return `${endpoint}/storage/buckets/media/files/${post.imagen_destacada_id}/preview?project=${projectId}&width=600&height=400&quality=80`; +} + +/** 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' }, +}; + +/** + * PostCard — Dashboard card for a single Post. + */ +export const PostCard: React.FC = ({ + post, + userRole, + onEdit, + onDelete, + appwriteEndpoint, + appwriteProjectId, +}) => { + const imageUrl = resolveImageUrl(post, appwriteEndpoint, appwriteProjectId); + + // Safe text rendering — no raw HTML + const safeTitle = decodeHtmlEntities(post.titulo); + const safeExcerpt = getPlainTextExcerpt(post.resumen_redes || post.contenido || ''); + + const badge = statusBadge[post.estado] ?? statusBadge['Borrador']; + + const fechaFormatted = new Date(post.fecha_publicacion || post.$createdAt).toLocaleDateString( + 'es-CO', + { day: '2-digit', month: 'short', year: 'numeric' } + ); + + const postType = + typeof post.tipo_publicacion_id === 'object' + ? post.tipo_publicacion_id.descripcion + : post.tipo_publicacion_id; + + return ( +
+ {/* Imagen Destacada */} +
+ {imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {safeTitle} + ) : ( +
+ +
+ )} + + {/* Estado Badge */} +
+ + {badge.label} + +
+ + {/* Edit Overlay — solo visible si el usuario tiene permisos */} + +
onEdit?.(post)} + role="button" + aria-label={`Editar: ${safeTitle}`} + > + + + Editar + +
+
+
+ + {/* Contenido */} +
+ {/* Tipo de publicación */} + {postType && ( + + {postType} + + )} + + {/* Título — safe text */} +

+ {safeTitle} +

+ + {/* Resumen — safe, no HTML */} +

+ {safeExcerpt} +

+ + {/* Footer */} +
+ {/* Fecha */} +
+ + {fechaFormatted} +
+ + {/* Acciones */} +
+ {/* Ver publicación (siempre visible) */} + + + + + {/* Editar — solo para editor/admin */} + + + + + {/* Eliminar — solo para admin */} + + + +
+
+
+
+ ); +}; + +export default PostCard; diff --git a/src/components/publications/PublicationDrawer.tsx b/src/components/publications/PublicationDrawer.tsx new file mode 100644 index 0000000..b6d259d --- /dev/null +++ b/src/components/publications/PublicationDrawer.tsx @@ -0,0 +1,168 @@ +'use client'; + +/** + * @file PublicationDrawer.tsx + * @description Slide-in SideDrawer for inline post editing. + * Opens when a user clicks a PostCard (gated by RBAC). + * Renders entirely in a Portal, focus-trapped, Escape-closeable. + */ + +import React, { useEffect, useRef, useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { X, Edit2, Loader2 } from 'lucide-react'; +import type { Post, AuthUser, PublicationLayoutProps } from '@/types/publications'; + +export interface PublicationDrawerProps { + isOpen: boolean; + post: Post | null; + user: AuthUser; + relationData: Record>; + submitAction: (formData: FormData) => Promise<{ success: boolean; error?: string; data?: unknown }>; + appwriteConfig: { + endpoint: string; + projectId: string; + databaseId: string; + }; + onClose: () => void; + onSuccess?: () => void; +} + +export const PublicationDrawer: React.FC = ({ + isOpen, + post, + user, + relationData, + submitAction, + appwriteConfig, + onClose, + onSuccess, +}) => { + const closeButtonRef = useRef(null); + + // Focus management + useEffect(() => { + if (isOpen) { + const timer = setTimeout(() => closeButtonRef.current?.focus(), 150); + return () => clearTimeout(timer); + } + }, [isOpen]); + + // Keyboard handler + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }, [onClose]); + + useEffect(() => { + if (isOpen) { + document.addEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'hidden'; + } + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [isOpen, handleKeyDown]); + + const wrappedSubmit = useCallback(async (formData: FormData) => { + const result = await submitAction(formData); + if (result?.success) { + onSuccess?.(); + } + return result; + }, [submitAction, onSuccess]); + + if (typeof document === 'undefined') return null; + + return createPortal( +
+ {/* Backdrop */} + , + document.body + ); +}; + +/** Lazy-loads PublicationLayout to code-split it from the list page */ +function LazyPublicationLayout(props: PublicationLayoutProps) { + const [Layout, setLayout] = useState | null>(null); + + useEffect(() => { + import('./PublicationLayout').then((mod) => { + setLayout(() => mod.default as React.ComponentType); + }); + }, []); + + if (!Layout) { + return ( +
+ + Cargando formulario… +
+ ); + } + + return ; +} + +export default PublicationDrawer; diff --git a/src/components/publications/PublicationLayout.tsx b/src/components/publications/PublicationLayout.tsx new file mode 100644 index 0000000..adbcab2 --- /dev/null +++ b/src/components/publications/PublicationLayout.tsx @@ -0,0 +1,496 @@ +'use client'; + +/** + * @file PublicationLayout.tsx + * @description 12-column grid layout for the Post creation/edit form. + * Organizes fields into logical sections: Datos Básicos, Contenido, + * Multimedia, SEO, Configuración, and Comercial. + * + * Integrates: + * - `FormSection` for visual grouping + * - `RelationSelectField` for relation fields with quick-create + * - `RichTextEditor` for contenido field + * - `usePublicationStore` for optimistic UI and state + * - Zod validation (client-side, before submission) + * - `PermissionGuard` to ensure only editor/admin can submit + */ + +import React, { useState, useTransition, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { + FileText, Image as ImageIcon, Search, Settings, Briefcase, + Save, ArrowLeft, AlertCircle, CheckCircle, Loader2, Eye +} from 'lucide-react'; +import Link from 'next/link'; + +import { PublicationLayoutProps } from '@/types/publications'; +import { PostSchema, generateSlug } from '@/lib/validators/publications'; +import { usePublicationStore } from '@/store/usePublicationStore'; +import FormSection from './FormSection'; +import RelationSelectField from './RelationSelectField'; +import PermissionGuard from './PermissionGuard'; + +// ───────────────────────────────────────────── +// FIELD COMPONENTS +// ───────────────────────────────────────────── + +function FieldWrapper({ + label, + required, + error, + children, + hint, + colSpan = 1, +}: { + label: string; + required?: boolean; + error?: string; + children: React.ReactNode; + hint?: string; + colSpan?: number; +}) { + const spanClass = colSpan === 2 ? 'md:col-span-2' : ''; + return ( +
+ + {children} + {hint && !error &&

{hint}

} + {error && ( +

+ {error} +

+ )} +
+ ); +} + +const inputClass = + 'w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-white text-sm ' + + 'focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500/30 ' + + 'hover:border-slate-700 transition-all placeholder:text-slate-700'; + +const textareaClass = inputClass + ' min-h-[100px] resize-y'; + +// ───────────────────────────────────────────── +// MAIN COMPONENT +// ───────────────────────────────────────────── + +export const PublicationLayout: React.FC = ({ + initialData, + relationData, + submitAction, + user, + appwriteConfig, +}) => { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const { setOptimisticState, optimisticState, errorMessage, reset, setRelationCache } = usePublicationStore(); + + // Form state + const [titulo, setTitulo] = useState(initialData?.titulo || ''); + const [slug, setSlug] = useState(initialData?.slug || ''); + const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug); + const [validationErrors, setValidationErrors] = useState>({}); + + // Populate relation cache in the store on mount + useEffect(() => { + Object.entries(relationData).forEach(([collection, data]) => { + setRelationCache(collection, data); + }); + return () => reset(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-generate slug from titulo + useEffect(() => { + if (isAutoSlug && titulo) { + setSlug(generateSlug(titulo)); + } + }, [titulo, isAutoSlug]); + + const userRole = (user?.prefs?.role as any) || 'viewer'; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const formData = new FormData(e.currentTarget); + + // Client-side Zod validation + const parseResult = PostSchema.safeParse(Object.fromEntries(formData.entries())); + if (!parseResult.success) { + const errors: Record = {}; + parseResult.error.errors.forEach((err) => { + const field = err.path[0] as string; + errors[field] = err.message; + }); + setValidationErrors(errors); + return; + } + + setValidationErrors({}); + setOptimisticState('saving'); + + startTransition(async () => { + try { + const result = await submitAction(formData); + if (result?.success) { + setOptimisticState('saved'); + setTimeout(() => router.push('/dashboard/posts'), 800); + } else { + setOptimisticState('error', result?.error || 'Error al guardar.'); + } + } catch (err: unknown) { + setOptimisticState('error', err instanceof Error ? err.message : 'Error desconocido.'); + } + }); + }; + + const isLoading = isPending || optimisticState === 'saving'; + const isSaved = optimisticState === 'saved'; + + return ( +
+ {/* Page Header */} +
+
+ + + +
+

+ {initialData?.$id ? 'Editar Publicación' : 'Nueva Publicación'} +

+

+ {initialData?.$id ? `ID: ${initialData.$id}` : 'Completa todos los campos requeridos.'} +

+
+
+ + {/* Status indicator */} +
+ {isSaved && ( + + Guardado + + )} + {optimisticState === 'error' && ( + + {errorMessage} + + )} +
+
+ +
+ {/* 12-column grid layout: left=8cols content, right=4cols sidebar */} +
+ + {/* ── LEFT COLUMN (8/12) ─────────────────────── */} +
+ + {/* SECCIÓN: Datos Básicos */} +
+ } columns={1}> + + {/* Título */} + + setTitulo(e.target.value)} + placeholder="Escribe un título llamativo..." + required + className={inputClass} + /> + + + {/* Slug */} + +
+ { setSlug(e.target.value); setIsAutoSlug(false); }} + placeholder="url-de-la-publicacion" + required + className={`${inputClass} font-mono`} + /> + +
+
+ + {/* Resumen */} + +