diff --git a/backend/api/v1/modules/core/help_center/models.py b/backend/api/v1/modules/core/help_center/models.py index 9b7e3608..c1d84662 100644 --- a/backend/api/v1/modules/core/help_center/models.py +++ b/backend/api/v1/modules/core/help_center/models.py @@ -21,6 +21,12 @@ class HelpArticle(Base): # Library Mode Fields category = Column(String(255), nullable=True, default="General") order = Column(Integer, nullable=True, default=0) + + # Multimedia Fields + content_type = Column(String(50), nullable=False, default="article") # article, pdf, video, image, document + file_url = Column(String(512), nullable=True) # URL to the uploaded asset + file_size = Column(Integer, nullable=True) # Size in bytes + mime_type = Column(String(100), nullable=True) # e.g. application/pdf def __repr__(self): return f"" diff --git a/backend/api/v1/modules/core/help_center/routes.py b/backend/api/v1/modules/core/help_center/routes.py index 19b8cb83..d8e3bd93 100644 --- a/backend/api/v1/modules/core/help_center/routes.py +++ b/backend/api/v1/modules/core/help_center/routes.py @@ -97,6 +97,40 @@ def upload_help_image(file: UploadFile = File(...)): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) +@router.post("/upload-asset/") +def upload_help_asset(file: UploadFile = File(...)): + """Sube cualquier tipo de archivo (PDF, Video, etc.) para la biblioteca.""" + try: + file_ext = os.path.splitext(file.filename)[1].lower() + new_filename = f"{uuid.uuid4()}{file_ext}" + + # Guardar en una carpeta segun el tipo o general + folder = "uploads/help/assets" + if file_ext in ['.pdf']: + folder = "uploads/help/pdfs" + elif file_ext in ['.mp4', '.mov', '.avi']: + folder = "uploads/help/videos" + + file_location = f"{folder}/{new_filename}" + + # Ensure directory exists + os.makedirs(folder, exist_ok=True) + + with open(file_location, "wb+") as buffer: + shutil.copyfileobj(file.file, buffer) + + # Get file size + file_size = os.path.getsize(file_location) + + return { + "url": f"/api/{file_location}", + "filename": file.filename, + "size": file_size, + "mime_type": file.content_type + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + @router.get("/articles/", response_model=List[HelpArticleInDB]) def list_articles(db: Session = Depends(get_core_db)): """Lista todos los artículos de ayuda.""" diff --git a/backend/api/v1/modules/core/help_center/schemas.py b/backend/api/v1/modules/core/help_center/schemas.py index 4e455e80..5f82995a 100644 --- a/backend/api/v1/modules/core/help_center/schemas.py +++ b/backend/api/v1/modules/core/help_center/schemas.py @@ -10,6 +10,10 @@ class HelpArticleBase(BaseModel): last_editor: str category: Optional[str] = "General" order: Optional[int] = 0 + content_type: str = "article" + file_url: Optional[str] = None + file_size: Optional[int] = None + mime_type: Optional[str] = None class HelpArticleCreate(HelpArticleBase): pass @@ -21,6 +25,10 @@ class HelpArticleUpdate(BaseModel): last_editor: Optional[str] = None category: Optional[str] = None order: Optional[int] = None + content_type: Optional[str] = None + file_url: Optional[str] = None + file_size: Optional[int] = None + mime_type: Optional[str] = None class HelpArticleInDB(HelpArticleBase): uuid: UUID @@ -38,6 +46,10 @@ class HelpSyncRequest(BaseModel): last_editor: str client_category: Optional[str] = "General" client_order: Optional[int] = 0 + client_content_type: str = "article" + client_file_url: Optional[str] = None + client_file_size: Optional[int] = None + client_mime_type: Optional[str] = None origin_client_uuid: Optional[UUID] = None class HelpSyncResponse(BaseModel): @@ -48,4 +60,8 @@ class HelpSyncResponse(BaseModel): server_slug: Optional[str] = None server_category: Optional[str] = None server_order: Optional[int] = None + server_content_type: Optional[str] = None + server_file_url: Optional[str] = None + server_file_size: Optional[int] = None + server_mime_type: Optional[str] = None message: str diff --git a/backend/api/v1/modules/core/help_center/services.py b/backend/api/v1/modules/core/help_center/services.py index 782791c7..78274d9c 100644 --- a/backend/api/v1/modules/core/help_center/services.py +++ b/backend/api/v1/modules/core/help_center/services.py @@ -82,7 +82,11 @@ class HelpCenterService: updated_at=client_updated_at, last_editor=sync_data.last_editor, category=sync_data.client_category, - order=sync_data.client_order + order=sync_data.client_order, + content_type=sync_data.client_content_type, + file_url=sync_data.client_file_url, + file_size=sync_data.client_file_size, + mime_type=sync_data.client_mime_type ) db.add(new_article) db.commit() @@ -101,6 +105,10 @@ class HelpCenterService: db_article.last_editor = sync_data.last_editor db_article.category = sync_data.client_category db_article.order = sync_data.client_order + db_article.content_type = sync_data.client_content_type + db_article.file_url = sync_data.client_file_url + db_article.file_size = sync_data.client_file_size + db_article.mime_type = sync_data.client_mime_type db.commit() return HelpSyncResponse(status="OK", message="Server updated with client data.") @@ -114,6 +122,10 @@ class HelpCenterService: server_slug=db_article.slug, server_category=db_article.category, server_order=db_article.order, + server_content_type=db_article.content_type, + server_file_url=db_article.file_url, + server_file_size=db_article.file_size, + server_mime_type=db_article.mime_type, message="Client is outdated. Update required." ) diff --git a/docker-compose.yml b/docker-compose.yml index 9efbf03d..1e756b46 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -128,13 +128,17 @@ services: "CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r - host: 127.0.0.1\r - Connection: close\r + \ host: 127.0.0.1\r - \r - ' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1" + \ Connection: close\r + + + \ \r + + + \ ' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1" ] interval: 10s timeout: 5s @@ -297,6 +301,27 @@ services: networks: - backend-net + celery_beat: + build: ./backend + container_name: celery_beat + command: celery -A core.celery_app beat --loglevel=info + environment: + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0} + - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} + - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} + - CLIENT_UUID=${CLIENT_UUID:-""} + - SPOKE_URLS=${SPOKE_URLS:-""} + - CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76} + - CORE_DB_PORT=${CORE_DB_PORT:-5432} + - CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core} + - CORE_DB_USER=${CORE_DB_USER:-postgres} + - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} + depends_on: + - backend + - valkey + networks: + - backend-net + valkey: image: valkey/valkey:7.2 container_name: valkey diff --git a/frontend/src/lib/api/help.ts b/frontend/src/lib/api/help.ts index 40749c85..7d1f4723 100644 --- a/frontend/src/lib/api/help.ts +++ b/frontend/src/lib/api/help.ts @@ -21,6 +21,10 @@ export interface HelpArticle { last_editor: string; category?: string; order?: number; + content_type: string; + file_url?: string; + file_size?: number; + mime_type?: string; } export const helpApi = { @@ -46,7 +50,7 @@ export const helpApi = { return response.json(); }, - async createArticle(data: { title: string; content: string; slug: string; last_editor: string; category?: string; order?: number }): Promise { + async createArticle(data: Partial): Promise { const response = await fetch(`${BASE_URL}/articles/`, { method: 'POST', headers: getHeaders(), @@ -82,5 +86,20 @@ export const helpApi = { }); if (!response.ok) throw new Error('Failed to upload image'); return response.json(); + }, + + async uploadAsset(file: File): Promise<{ url: string, filename: string, size: number, mime_type: string }> { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`${BASE_URL}/upload-asset/`, { + method: 'POST', + headers: { + ...(get(authStore).token ? { 'Authorization': `Bearer ${get(authStore).token}` } : {}) + }, + body: formData + }); + if (!response.ok) throw new Error('Failed to upload asset'); + return response.json(); } }; diff --git a/frontend/src/routes/dashboard/help-center/+page.svelte b/frontend/src/routes/dashboard/help-center/+page.svelte index 6fabbde4..efb8d4c9 100644 --- a/frontend/src/routes/dashboard/help-center/+page.svelte +++ b/frontend/src/routes/dashboard/help-center/+page.svelte @@ -8,12 +8,24 @@ import { Input } from '$lib/components/ui/input/index.js'; import { Textarea } from '$lib/components/ui/textarea/index.js'; import { Label } from '$lib/components/ui/label/index.js'; - import { Plus, Trash2, Edit, Loader2, Search, Book, Image as ImageIcon } from 'lucide-svelte'; + import { + Plus, + Trash2, + Edit, + Loader2, + Search, + Book, + Image as ImageIcon, + FileText, + Video, + FileCode + } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; let articles: HelpArticle[] = $state([]); let loading = $state(true); let searchTerm = $state(''); + const HUB_MODE = import.meta.env.VITE_HUB_MODE === 'true'; // Modals state let showCreateModal = $state(false); @@ -108,10 +120,12 @@

Manuales, Guías y Documentación del Sistema.

- + {#if HUB_MODE} + + {/if}
@@ -158,8 +172,17 @@ > + {#if article.content_type === 'pdf'} + + {:else if article.content_type === 'video'} + @@ -167,31 +190,42 @@ -
- {@html article.content.replace(/<[^>]*>?/gm, '').substring(0, 150)}... -
+ {#if article.content_type === 'article'} +
+ {@html (article.content || '').replace(/<[^>]*>?/gm, '').substring(0, 150)}... +
+ {:else} +
+

Formato: {article.mime_type || 'Desconocido'}

+ {#if article.file_size} +

Tamaño: {(article.file_size / 1024 / 1024).toFixed(2)} MB

+ {/if} +
+ {/if}
- - + {#if HUB_MODE} + + + {/if}
diff --git a/frontend/src/routes/dashboard/help-center/[uuid]/+page.svelte b/frontend/src/routes/dashboard/help-center/[uuid]/+page.svelte index 66a799b4..e9f78463 100644 --- a/frontend/src/routes/dashboard/help-center/[uuid]/+page.svelte +++ b/frontend/src/routes/dashboard/help-center/[uuid]/+page.svelte @@ -3,7 +3,16 @@ import { onMount } from 'svelte'; import { helpApi, type HelpArticle } from '$lib/api/help'; import { Button } from '$lib/components/ui/button/index.js'; - import { Loader2, ArrowLeft, Calendar, User, BookOpen } from 'lucide-svelte'; + import { + Loader2, + ArrowLeft, + Calendar, + User, + BookOpen, + Download, + FileText, + ExternalLink + } from 'lucide-svelte'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; @@ -124,11 +133,72 @@ -
- {@html renderMarkdown(article.content)} -
+ {#if article.content_type === 'article'} +
+ {@html renderMarkdown(article.content)} +
+ {:else if article.content_type === 'pdf'} +
+
+
+ +
+

Documento PDF

+

+ {article.file_size ? (article.file_size / 1024 / 1024).toFixed(2) : '??'} MB +

+
+
+
+ + +
+
+ + +
+ {:else if article.content_type === 'video'} +
+ +
+

Información del archivo

+

{article.mime_type}

+
+
+ {:else} +
+
+ +
+
+

Archivo para descargar

+

+ Este archivo no tiene vista previa directa. +

+
+ +
+ {/if} diff --git a/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte b/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte index a098954a..168ec6a4 100644 --- a/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte +++ b/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte @@ -16,8 +16,11 @@ Italic, Link as LinkIcon, List, - Heading1, - Heading2 + Heading2, + FileText, + Video as VideoIcon, + FileCode, + Upload } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import { marked } from 'marked'; @@ -33,12 +36,23 @@ let content = ''; let category = 'General'; let order = 0; - let last_editor = 'Admin'; // Could pull from auth store + let last_editor = 'Admin'; + let content_type = 'article'; + let file_url = ''; + let file_size: number | null = null; + let mime_type = ''; // Preview State let showPreview = true; + const HUB_MODE = import.meta.env.VITE_HUB_MODE === 'true'; onMount(async () => { + // Proteccion: Solo el Hub puede editar/crear + if (!HUB_MODE) { + window.location.href = '/dashboard/help-center'; + return; + } + const uuid = $page.params.uuid as string; if (uuid === 'new') { loading = false; @@ -51,9 +65,13 @@ if (article) { title = article.title; slug = article.slug; - content = article.content; + content = article.content || ''; category = article.category || 'General'; order = article.order || 0; + content_type = article.content_type || 'article'; + file_url = article.file_url || ''; + file_size = article.file_size || null; + mime_type = article.mime_type || ''; } } catch (e: any) { toast.error('Error al cargar artículo: ' + e.message); @@ -65,7 +83,10 @@ async function handleSave() { processing = true; try { - const data = { title, slug, content, last_editor, category, order }; + const data = { + title, slug, content, last_editor, category, order, + content_type, file_url, file_size, mime_type + }; // Auto-generate slug if missing if (!data.slug) { @@ -128,6 +149,30 @@ } } + async function handleAssetUpload(file: File) { + try { + processing = true; + toast.loading(`Subiendo ${file.name}...`); + const result = await helpApi.uploadAsset(file); + file_url = result.url; + file_size = result.size; + mime_type = result.mime_type; + + // Auto-infer content type if not set or article + if (content_type === 'article') { + if (mime_type === 'application/pdf') content_type = 'pdf'; + else if (mime_type.startsWith('video/')) content_type = 'video'; + else content_type = 'document'; + } + + toast.success('Archivo subido correctamente'); + } catch (e: any) { + toast.error('Error al subir archivo: ' + e.message); + } finally { + processing = false; + } + } + function handleDrop(e: DragEvent) { e.preventDefault(); const files = e.dataTransfer?.files; @@ -139,11 +184,12 @@ } } - import { browser } from '$app/environment'; - function renderMarkdown(text: string) { - // @ts-ignore - return DOMPurify.sanitize(marked.parse(text || '') as string); + const html = marked.parse(text || '') as string; + if (browser) { + return DOMPurify.sanitize(html); + } + return html; } @@ -195,110 +241,203 @@ +
+ + +
- -
- -
- + +
+ + +
+ + + +
+ + + + +
- + + {#if showPreview} +
+
+ {@html renderMarkdown(content)} +
+
+ {/if} + {:else} + +
+
+
+ {#if content_type === 'pdf'} +
+ +
+

Configuración de PDF

+ {:else if content_type === 'video'} +
+ +
+

Configuración de Video

+ {:else} +
+ +
+

Configuración de Documento

+ {/if} +

+ Sube el archivo que deseas asociar a este título. +

+
- - -
- - - {#if showPreview} -
-
- {@html renderMarkdown(content)} + {#if file_url} +
+
+
+ +
+
+

Archivo Cargado

+

{file_url}

+

+ {mime_type} • {(file_size ? (file_size / 1024 / 1024).toFixed(2) : '??')} MB +

+
+
+
+ + +
+
+ {:else} + + {/if}
{/if} diff --git a/start.sh b/start.sh index e699ea4d..9bb15911 100755 --- a/start.sh +++ b/start.sh @@ -77,8 +77,10 @@ if [ ! -f .env ]; then CENTRAL_URL="" CLIENT_ID="" SPOKE_URLS="" + HUB_MODE="true" else echo -e "${GREEN}Configurando como CLIENTE...${NC}" + HUB_MODE="false" read -p "Ingrese la URL del Hub [http://100.78.6.108:8001/api/v1/core/help-center/sync/]: " CENTRAL_URL CENTRAL_URL=${CENTRAL_URL:-http://100.78.6.108:8001/api/v1/core/help-center/sync/} read -p "Ingrese el UUID de este cliente (presione Enter para generar uno): " CLIENT_ID @@ -95,6 +97,12 @@ if [ ! -f .env ]; then sed -i "s|SPOKE_URLS=.*|SPOKE_URLS=$SPOKE_URLS|" .env sed -i "s|CLIENT_UUID=.*|CLIENT_UUID=$CLIENT_ID|" .env sed -i "s|SYNC_SECRET_TOKEN=.*|SYNC_SECRET_TOKEN=$SYNC_TOKEN|" .env + # Agregar o actualizar VITE_HUB_MODE + if grep -q "VITE_HUB_MODE=" .env; then + sed -i "s|VITE_HUB_MODE=.*|VITE_HUB_MODE=$HUB_MODE|" .env + else + echo "VITE_HUB_MODE=$HUB_MODE" >> .env + fi else echo -e "${YELLOW}⚠ .env.example no existe, creando .env básico${NC}" cat > .env <