Resolucion de conflictos: Merge feature/boton_ayuda a development
This commit is contained in:
39
.env.example
39
.env.example
@@ -27,15 +27,44 @@ CORE_DB_NAME=anexo76_core
|
|||||||
CORE_DB_USER=postgres
|
CORE_DB_USER=postgres
|
||||||
CORE_DB_PASSWORD=postgres
|
CORE_DB_PASSWORD=postgres
|
||||||
|
|
||||||
|
# Lista de orígenes permitidos (CORS)
|
||||||
|
# Ejemplo para Hub: http://localhost:5173,http://100.78.6.108:5174,http://100.78.6.108:8001
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# ---- API Tipo cambio (External) ---
|
||||||
|
EXTERNAL_API_URL=http://74.208.80.245:3000
|
||||||
|
EXTERNAL_API_USER=devAS
|
||||||
|
EXTERNAL_API_PASSWORD=T6Y4mqP09Y[-2-3H
|
||||||
|
|
||||||
|
# ----- API SITAR (Warnings) ---
|
||||||
|
SITAR_API_URL=""
|
||||||
|
SITAR_API_USER=""
|
||||||
|
SITAR_API_PASSWORD=""
|
||||||
|
|
||||||
# ----- Frontend -----
|
# ----- Frontend -----
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
VITE_API_URL=http://localhost:8000/api
|
VITE_API_URL=http://localhost:8000/api
|
||||||
INTERNAL_API_URL=http://backend:8000/api
|
INTERNAL_API_URL=http://backend:8000/api
|
||||||
VITE_KEYCLOAK_REALM=master
|
VITE_KEYCLOAK_REALM=master
|
||||||
VITE_KEYCLOAK_URL=http://localhost:8080
|
VITE_KEYCLOAK_URL=http://localhost:8080/kcauth
|
||||||
VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend
|
VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend
|
||||||
|
|
||||||
# ----- Sitar API -----
|
#------ Celery / Valkey ----------
|
||||||
SITAR_API_URL=http://api.sitar.aduanasoft.com
|
VALKEY_URL=redis://valkey:6379/0
|
||||||
SITAR_API_USER=your_sitar_user
|
|
||||||
SITAR_API_PASSWORD=your_sitar_password
|
# ==================================
|
||||||
|
# CONFIGURACIÓN DE SINCRONIZACIÓN
|
||||||
|
# ==================================
|
||||||
|
# Hub IP: 100.78.6.108
|
||||||
|
|
||||||
|
# URL del Hub para sincronización (Solo si es CLIENTE)
|
||||||
|
# Ejemplo: http://100.78.6.108:8001/api/v1/core/help-center/sync/
|
||||||
|
CENTRAL_SERVER_URL=
|
||||||
|
|
||||||
|
# UUID único de este cliente (Opcional, se genera uno si está vacío)
|
||||||
|
|
||||||
|
# Token de seguridad compartido (Debe ser IDÉNTICO en Hub y Clientes)
|
||||||
|
SYNC_SECRET_TOKEN=change-this-sync-token-in-production
|
||||||
|
|
||||||
|
# Lista de spokes (Solo si es HUB y desea retransmitir a otros - Opcional)
|
||||||
|
SPOKE_URLS=""
|
||||||
|
|||||||
@@ -27,3 +27,8 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
|||||||
|
|
||||||
# License Service
|
# License Service
|
||||||
LICENSE_CHECK_ENABLED=True
|
LICENSE_CHECK_ENABLED=True
|
||||||
|
|
||||||
|
# Synchronization (Hub & Spoke)
|
||||||
|
SYNC_SECRET_TOKEN=change-this-sync-token-in-production
|
||||||
|
# Only for spokes/clients. Leave empty if this is the Hub.
|
||||||
|
CENTRAL_SERVER_URL=http://localhost:8000/api/v1/core/help-center/sync/
|
||||||
|
|||||||
32
backend/api/v1/modules/core/help_center/models.py
Normal file
32
backend/api/v1/modules/core/help_center/models.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Column, String, Text, DateTime, Integer
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from core.database import Base
|
||||||
|
|
||||||
|
class HelpArticle(Base):
|
||||||
|
"""
|
||||||
|
Modelo para los artículos de ayuda (Base de Conocimientos).
|
||||||
|
Sincronizado entre Servidor Central y Clientes.
|
||||||
|
"""
|
||||||
|
__tablename__ = "help_articles"
|
||||||
|
|
||||||
|
uuid = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||||
|
slug = Column(String(255), unique=True, index=True, nullable=False)
|
||||||
|
title = Column(String(255), nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
last_editor = Column(String(255), nullable=False)
|
||||||
|
|
||||||
|
# Library Mode Fields
|
||||||
|
category = Column(String(255), nullable=True, default="General")
|
||||||
|
order = Column(Integer, nullable=True, default=0)
|
||||||
|
|
||||||
|
# Removed missing fields to avoid 500 errors (No migration approach)
|
||||||
|
# content_type = Column(String(50), nullable=False, default="article")
|
||||||
|
# file_url = Column(String(512), nullable=True)
|
||||||
|
# file_size = Column(Integer, nullable=True)
|
||||||
|
# mime_type = Column(String(100), nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<HelpArticle(title='{self.title}', slug='{self.slug}')>"
|
||||||
227
backend/api/v1/modules/core/help_center/routes.py
Normal file
227
backend/api/v1/modules/core/help_center/routes.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import shutil
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from uuid import UUID
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header, status, UploadFile, File
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.config import settings
|
||||||
|
from core.security import get_current_user, has_role
|
||||||
|
from .schemas import HelpArticleInDB, HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse
|
||||||
|
from .services import HelpCenterService
|
||||||
|
from .tasks import sync_single_article_task
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/help-center", tags=["Help Center"])
|
||||||
|
|
||||||
|
def verify_sync_token(x_sync_token: str = Header(...)):
|
||||||
|
if x_sync_token != settings.SYNC_SECRET_TOKEN:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid Sync Token"
|
||||||
|
)
|
||||||
|
|
||||||
|
def trigger_sync_or_broadcast(article_uuid: UUID):
|
||||||
|
"""
|
||||||
|
Helper function to handle synchronization logic.
|
||||||
|
- If we are a Client (CENTRAL_SERVER_URL is set): Trigger upstream sync.
|
||||||
|
- If we are the Hub (No CENTRAL_SERVER, but SPOKE_URLS set): Trigger broadcast.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"DEBUG: Triggering sync/broadcast for article {article_uuid}")
|
||||||
|
logger.debug(f"DEBUG: CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'")
|
||||||
|
|
||||||
|
# 1. Upstream Sync (Client -> Hub)
|
||||||
|
if settings.CENTRAL_SERVER_URL and settings.CENTRAL_SERVER_URL != '""':
|
||||||
|
logger.info(f"DEBUG: Queueing sync_single_article_task for {article_uuid}")
|
||||||
|
sync_single_article_task.delay(str(article_uuid))
|
||||||
|
|
||||||
|
# 2. Downstream Broadcast (Hub -> Spokes)
|
||||||
|
# Only if we are the Hub (no upstream) and have spokes configured.
|
||||||
|
elif (not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""') and settings.SPOKE_URLS:
|
||||||
|
from .tasks import broadcast_help_update
|
||||||
|
logger.info(f"DEBUG: Queueing broadcast_help_update for {article_uuid}")
|
||||||
|
# origin_client_uuid is None because this change originated on the Hub itself
|
||||||
|
broadcast_help_update.delay(str(article_uuid), None)
|
||||||
|
else:
|
||||||
|
logger.info(f"DEBUG: No sync/broadcast needed for {article_uuid} (Config empty or Hub mode without spokes)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"ERROR in trigger_sync_or_broadcast for article {article_uuid}: {str(e)}", exc_info=True)
|
||||||
|
# We don't re-raise here to avoid returning 500 to the user if the save was successful
|
||||||
|
|
||||||
|
@router.post("/sync/", response_model=HelpSyncResponse, dependencies=[Depends(verify_sync_token)])
|
||||||
|
def sync_help_article(sync_data: HelpSyncRequest, db: Session = Depends(get_core_db)):
|
||||||
|
"""
|
||||||
|
Endpoint de sincronización inteligente para artículos de ayuda.
|
||||||
|
Requiere X-Sync-Token en los headers.
|
||||||
|
"""
|
||||||
|
result = HelpCenterService.sync_article(db, sync_data)
|
||||||
|
|
||||||
|
# Broadcast to other spokes (Hub logic)
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info(f"DEBUG: Hub Sync Check. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'")
|
||||||
|
|
||||||
|
if not settings.CENTRAL_SERVER_URL and settings.SPOKE_URLS:
|
||||||
|
# We are the Hub (no central server to push to) and have Spokes configured
|
||||||
|
from .tasks import broadcast_help_update
|
||||||
|
logger.info(f"DEBUG: Triggering broadcast for article {sync_data.article_uuid}")
|
||||||
|
broadcast_help_update.delay(
|
||||||
|
str(sync_data.article_uuid),
|
||||||
|
str(sync_data.origin_client_uuid) if sync_data.origin_client_uuid else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("DEBUG: Broadcast skipped (Condition failed)")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@router.post("/upload-image/")
|
||||||
|
def upload_help_image(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||||
|
):
|
||||||
|
"""Sube una imagen para usar en los artículos."""
|
||||||
|
try:
|
||||||
|
file_ext = os.path.splitext(file.filename)[1]
|
||||||
|
new_filename = f"{uuid.uuid4()}{file_ext}"
|
||||||
|
file_location = f"uploads/help/{new_filename}"
|
||||||
|
|
||||||
|
# Ensure directory exists
|
||||||
|
os.makedirs("uploads/help", exist_ok=True)
|
||||||
|
|
||||||
|
with open(file_location, "wb+") as buffer:
|
||||||
|
shutil.copyfileobj(file.file, buffer)
|
||||||
|
|
||||||
|
return {"url": f"/api/uploads/help/{new_filename}"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.post("/upload-asset/")
|
||||||
|
def upload_help_asset(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||||
|
):
|
||||||
|
"""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),
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Lista todos los artículos de ayuda."""
|
||||||
|
return HelpCenterService.get_all(db)
|
||||||
|
|
||||||
|
@router.get("/modifications/", response_model=List[HelpArticleInDB], dependencies=[Depends(verify_sync_token)])
|
||||||
|
def get_modifications(since: datetime, db: Session = Depends(get_core_db)):
|
||||||
|
"""Obtiene artículos modificados desde la fecha indicada (Polling). Requiere X-Sync-Token."""
|
||||||
|
return HelpCenterService.get_modifications(db, since)
|
||||||
|
|
||||||
|
@router.get("/articles/{article_uuid}/", response_model=HelpArticleInDB)
|
||||||
|
def get_article(
|
||||||
|
article_uuid: UUID,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Obtiene un artículo por UUID."""
|
||||||
|
article = HelpCenterService.get_by_uuid(db, article_uuid)
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(status_code=404, detail="Article not found")
|
||||||
|
return article
|
||||||
|
|
||||||
|
@router.post("/articles/", response_model=HelpArticleInDB, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_article(
|
||||||
|
article: HelpArticleCreate,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||||
|
):
|
||||||
|
"""Crea un nuevo artículo."""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info(f"DEBUG: Creating new article: {article.title} by {current_user.get('preferred_username')}")
|
||||||
|
|
||||||
|
# Fill last_editor with admin username
|
||||||
|
if current_user.get('preferred_username'):
|
||||||
|
article.last_editor = current_user.get('preferred_username')
|
||||||
|
|
||||||
|
new_article = HelpCenterService.create(db, article)
|
||||||
|
logger.info(f"DEBUG: Article created successfully in DB. UUID: {new_article.uuid}")
|
||||||
|
|
||||||
|
trigger_sync_or_broadcast(new_article.uuid)
|
||||||
|
|
||||||
|
return new_article
|
||||||
|
|
||||||
|
@router.patch("/articles/{article_uuid}/", response_model=HelpArticleInDB)
|
||||||
|
def update_article(
|
||||||
|
article_uuid: UUID,
|
||||||
|
article_data: HelpArticleUpdate,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||||
|
):
|
||||||
|
"""Actualiza un artículo."""
|
||||||
|
if current_user.get('preferred_username'):
|
||||||
|
article_data.last_editor = current_user.get('preferred_username')
|
||||||
|
|
||||||
|
article = HelpCenterService.update(db, article_uuid, article_data)
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(status_code=404, detail="Article not found")
|
||||||
|
|
||||||
|
trigger_sync_or_broadcast(article.uuid)
|
||||||
|
|
||||||
|
return article
|
||||||
|
|
||||||
|
@router.delete("/articles/{article_uuid}/", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_article(
|
||||||
|
article_uuid: UUID,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||||
|
):
|
||||||
|
"""Elimina un artículo."""
|
||||||
|
if not HelpCenterService.delete(db, article_uuid):
|
||||||
|
raise HTTPException(status_code=404, detail="Article not found")
|
||||||
|
|
||||||
|
# Broadcast or Sync the deletion?
|
||||||
|
# Current sync logic relies on sending the *content*. Deletion sync is harder because the article is gone.
|
||||||
|
# For now, let's at least trigger the logic.
|
||||||
|
# WARNING: sync_single_article_task expects the article to exist to send it.
|
||||||
|
# If we deleted it locally, sync_single_article_task will fail or send nothing.
|
||||||
|
# We need a dedicated 'sync_deletion' task or similar.
|
||||||
|
# Since the user didn't explicitly ask for deletion sync, I will SKIP adding complex deletion sync
|
||||||
|
# logic right now to avoid breaking things, but I'll add the hook for completeness.
|
||||||
|
# Actually, better to NOT trigger sync on delete if we don't handle it, to avoid errors in logs.
|
||||||
|
|
||||||
|
return None
|
||||||
66
backend/api/v1/modules/core/help_center/schemas.py
Normal file
66
backend/api/v1/modules/core/help_center/schemas.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class HelpArticleBase(BaseModel):
|
||||||
|
slug: str
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
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
|
||||||
|
|
||||||
|
class HelpArticleUpdate(BaseModel):
|
||||||
|
slug: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
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
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class HelpSyncRequest(BaseModel):
|
||||||
|
article_uuid: UUID
|
||||||
|
client_updated_at: datetime
|
||||||
|
client_content: str
|
||||||
|
client_title: str
|
||||||
|
client_slug: str
|
||||||
|
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
|
||||||
|
|
||||||
|
class HelpSyncResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
server_updated_at: Optional[datetime] = None
|
||||||
|
server_content: Optional[str] = None
|
||||||
|
server_title: Optional[str] = None
|
||||||
|
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
|
||||||
240
backend/api/v1/modules/core/help_center/services.py
Normal file
240
backend/api/v1/modules/core/help_center/services.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from .models import HelpArticle
|
||||||
|
from .schemas import HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse
|
||||||
|
|
||||||
|
class HelpCenterService:
|
||||||
|
@staticmethod
|
||||||
|
def _inject_metadata(article: HelpArticle) -> HelpArticle:
|
||||||
|
if not article or not article.content:
|
||||||
|
return article
|
||||||
|
|
||||||
|
# Look for <!-- a76_metadata: { ... } -->
|
||||||
|
match = re.search(r'<!-- a76_metadata: (.*?) -->', article.content, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
metadata = json.loads(match.group(1))
|
||||||
|
article.content_type = metadata.get("content_type", "article")
|
||||||
|
article.file_url = metadata.get("file_url")
|
||||||
|
article.file_size = metadata.get("file_size")
|
||||||
|
article.mime_type = metadata.get("mime_type")
|
||||||
|
# Remove metadata from content for clean display if needed,
|
||||||
|
# but usually better to leave it and let parser handle it or hide it here.
|
||||||
|
# For now, we just set the attributes.
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
article.content_type = "article"
|
||||||
|
article.file_url = None
|
||||||
|
article.file_size = None
|
||||||
|
article.mime_type = None
|
||||||
|
|
||||||
|
return article
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_metadata(content: str, data: dict) -> str:
|
||||||
|
# Remove existing metadata block if any
|
||||||
|
content = re.sub(r'\n\n<!-- a76_metadata: .*? -->', '', content, flags=re.DOTALL)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"content_type": data.get("content_type", "article"),
|
||||||
|
"file_url": data.get("file_url"),
|
||||||
|
"file_size": data.get("file_size"),
|
||||||
|
"mime_type": data.get("mime_type")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only append if there's something meaningful beyond "article"
|
||||||
|
if metadata["content_type"] != "article" or metadata["file_url"]:
|
||||||
|
content += f"\n\n<!-- a76_metadata: {json.dumps(metadata)} -->"
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_all(db: Session) -> List[HelpArticle]:
|
||||||
|
articles = db.query(HelpArticle).all()
|
||||||
|
return [HelpCenterService._inject_metadata(a) for a in articles]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_uuid(db: Session, article_uuid: UUID) -> Optional[HelpArticle]:
|
||||||
|
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||||
|
return HelpCenterService._inject_metadata(article)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_slug(db: Session, slug: str) -> Optional[HelpArticle]:
|
||||||
|
article = db.query(HelpArticle).filter(HelpArticle.slug == slug).first()
|
||||||
|
return HelpCenterService._inject_metadata(article)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_modifications(db: Session, since: datetime) -> List[HelpArticle]:
|
||||||
|
# Ensure timezone awareness
|
||||||
|
if since.tzinfo is None:
|
||||||
|
since = since.replace(tzinfo=timezone.utc)
|
||||||
|
articles = db.query(HelpArticle).filter(HelpArticle.updated_at > since).all()
|
||||||
|
return [HelpCenterService._inject_metadata(a) for a in articles]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create(db: Session, article: HelpArticleCreate) -> HelpArticle:
|
||||||
|
data = article.model_dump()
|
||||||
|
# Move metadata into content
|
||||||
|
data["content"] = HelpCenterService._extract_metadata(data["content"], data)
|
||||||
|
# Remove virtual fields from data to avoid SQLAlchemy errors
|
||||||
|
virtual_fields = ["content_type", "file_url", "file_size", "mime_type"]
|
||||||
|
for f in virtual_fields:
|
||||||
|
if f in data:
|
||||||
|
del data[f]
|
||||||
|
|
||||||
|
db_article = HelpArticle(**data)
|
||||||
|
db.add(db_article)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_article)
|
||||||
|
return HelpCenterService._inject_metadata(db_article)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(db: Session, article_uuid: UUID, article_data: HelpArticleUpdate) -> Optional[HelpArticle]:
|
||||||
|
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||||
|
if not db_article:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Inject metadata to existing article to get current virtual fields
|
||||||
|
db_article = HelpCenterService._inject_metadata(db_article)
|
||||||
|
|
||||||
|
update_data = article_data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Handle metadata update
|
||||||
|
if "content" in update_data or any(f in update_data for f in ["content_type", "file_url", "file_size", "mime_type"]):
|
||||||
|
# Merge existing metadata with new updates
|
||||||
|
current_meta = {
|
||||||
|
"content_type": getattr(db_article, "content_type", "article"),
|
||||||
|
"file_url": getattr(db_article, "file_url", None),
|
||||||
|
"file_size": getattr(db_article, "file_size", None),
|
||||||
|
"mime_type": getattr(db_article, "mime_type", None)
|
||||||
|
}
|
||||||
|
# Update with new data if present
|
||||||
|
for f in ["content_type", "file_url", "file_size", "mime_type"]:
|
||||||
|
if f in update_data:
|
||||||
|
current_meta[f] = update_data[f]
|
||||||
|
|
||||||
|
# Use current content or new content
|
||||||
|
content = update_data.get("content", db_article.content)
|
||||||
|
update_data["content"] = HelpCenterService._extract_metadata(content, current_meta)
|
||||||
|
|
||||||
|
# Remove virtual fields from data
|
||||||
|
virtual_fields = ["content_type", "file_url", "file_size", "mime_type"]
|
||||||
|
for f in virtual_fields:
|
||||||
|
if f in update_data:
|
||||||
|
del update_data[f]
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(db_article, key, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_article)
|
||||||
|
return HelpCenterService._inject_metadata(db_article)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete(db: Session, article_uuid: UUID) -> bool:
|
||||||
|
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||||
|
if not db_article:
|
||||||
|
return False
|
||||||
|
db.delete(db_article)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sync_article(db: Session, sync_data: HelpSyncRequest) -> HelpSyncResponse:
|
||||||
|
"""
|
||||||
|
Lógica de sincronización "Smart Sync" (Last Write Wins).
|
||||||
|
"""
|
||||||
|
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == sync_data.article_uuid).first()
|
||||||
|
|
||||||
|
client_updated_at = sync_data.client_updated_at
|
||||||
|
if client_updated_at.tzinfo is None:
|
||||||
|
client_updated_at = client_updated_at.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if not db_article:
|
||||||
|
# Caso A: Artículo nuevo desde el cliente
|
||||||
|
# Store metadata in content
|
||||||
|
client_meta = {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
content_with_meta = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
|
||||||
|
|
||||||
|
new_article = HelpArticle(
|
||||||
|
uuid=sync_data.article_uuid,
|
||||||
|
slug=sync_data.client_slug,
|
||||||
|
title=sync_data.client_title,
|
||||||
|
content=content_with_meta,
|
||||||
|
updated_at=client_updated_at,
|
||||||
|
last_editor=sync_data.last_editor,
|
||||||
|
category=sync_data.client_category,
|
||||||
|
order=sync_data.client_order
|
||||||
|
)
|
||||||
|
db.add(new_article)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Download assets if needed (Images in content and main file)
|
||||||
|
from .utils import download_file_from_hub, sync_assets_from_content
|
||||||
|
if sync_data.client_file_url:
|
||||||
|
download_file_from_hub(sync_data.client_file_url)
|
||||||
|
sync_assets_from_content(sync_data.client_content)
|
||||||
|
|
||||||
|
return HelpSyncResponse(status="OK", message="Article created on server.")
|
||||||
|
|
||||||
|
server_updated_at = db_article.updated_at
|
||||||
|
if server_updated_at.tzinfo is None:
|
||||||
|
server_updated_at = server_updated_at.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Caso A: Cliente es más nuevo
|
||||||
|
if client_updated_at > server_updated_at:
|
||||||
|
client_meta = {
|
||||||
|
"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_article.content = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
|
||||||
|
db_article.title = sync_data.client_title
|
||||||
|
db_article.slug = sync_data.client_slug
|
||||||
|
db_article.updated_at = client_updated_at
|
||||||
|
db_article.last_editor = sync_data.last_editor
|
||||||
|
db_article.category = sync_data.client_category
|
||||||
|
db_article.order = sync_data.client_order
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Download assets if needed (Images in content)
|
||||||
|
from .utils import download_file_from_hub, sync_assets_from_content
|
||||||
|
if sync_data.client_file_url:
|
||||||
|
download_file_from_hub(sync_data.client_file_url)
|
||||||
|
sync_assets_from_content(sync_data.client_content)
|
||||||
|
|
||||||
|
return HelpSyncResponse(status="OK", message="Server updated with client data.")
|
||||||
|
|
||||||
|
# Caso B: Servidor es más nuevo
|
||||||
|
elif server_updated_at > client_updated_at:
|
||||||
|
# Inject metadata for response
|
||||||
|
db_article = HelpCenterService._inject_metadata(db_article)
|
||||||
|
return HelpSyncResponse(
|
||||||
|
status="UPDATE_REQUIRED",
|
||||||
|
server_updated_at=server_updated_at,
|
||||||
|
server_content=db_article.content,
|
||||||
|
server_title=db_article.title,
|
||||||
|
server_slug=db_article.slug,
|
||||||
|
server_category=db_article.category,
|
||||||
|
server_order=db_article.order,
|
||||||
|
server_content_type=getattr(db_article, "content_type", "article"),
|
||||||
|
server_file_url=getattr(db_article, "file_url", None),
|
||||||
|
server_file_size=getattr(db_article, "file_size", None),
|
||||||
|
server_mime_type=getattr(db_article, "mime_type", None),
|
||||||
|
message="Client is outdated. Update required."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Caso C: Iguales
|
||||||
|
else:
|
||||||
|
return HelpSyncResponse(status="OK", message="Already in sync.")
|
||||||
269
backend/api/v1/modules/core/help_center/tasks.py
Normal file
269
backend/api/v1/modules/core/help_center/tasks.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
from uuid import UUID
|
||||||
|
from celery import shared_task
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from core.database import CoreSessionLocal
|
||||||
|
from core.config import settings
|
||||||
|
from .models import HelpArticle
|
||||||
|
from .schemas import HelpSyncRequest, HelpSyncResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@shared_task(name="sync_all_articles_task")
|
||||||
|
def sync_all_articles_task():
|
||||||
|
"""
|
||||||
|
Tarea periódica que recorre todos los artículos locales y los sincroniza con el Central.
|
||||||
|
Solo se ejecuta si hay un CENTRAL_SERVER_URL configurado (Rol: Cliente/Spoke).
|
||||||
|
"""
|
||||||
|
if not settings.CENTRAL_SERVER_URL:
|
||||||
|
logger.info("Skipping sync: No CENTRAL_SERVER_URL configured (Hub mode).")
|
||||||
|
return
|
||||||
|
db = CoreSessionLocal()
|
||||||
|
try:
|
||||||
|
articles = db.query(HelpArticle).all()
|
||||||
|
for article in articles:
|
||||||
|
sync_single_article(article.uuid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in sync_all_articles_task: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@shared_task(name="sync_single_article_task")
|
||||||
|
def sync_single_article_task(article_uuid_str: str):
|
||||||
|
"""
|
||||||
|
Sincroniza un único artículo inmediatamente después de una edición local.
|
||||||
|
"""
|
||||||
|
sync_single_article(article_uuid_str)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="broadcast_help_update")
|
||||||
|
def broadcast_help_update(article_uuid_str: str):
|
||||||
|
"""
|
||||||
|
Difunde una actualización de artículo a todos los spokes configurados.
|
||||||
|
"""
|
||||||
|
if not settings.SPOKE_URLS:
|
||||||
|
logger.info("No SPOKE_URLS configured. Skipping broadcast.")
|
||||||
|
return
|
||||||
|
|
||||||
|
spokes = [s.strip() for s in settings.SPOKE_URLS.split(",") if s.strip()]
|
||||||
|
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
|
||||||
|
|
||||||
|
db = CoreSessionLocal()
|
||||||
|
try:
|
||||||
|
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid_str).first()
|
||||||
|
if not article:
|
||||||
|
logger.error(f"Article {article_uuid_str} not found for broadcast.")
|
||||||
|
return
|
||||||
|
|
||||||
|
sync_payload = HelpSyncRequest(
|
||||||
|
article_uuid=article.uuid,
|
||||||
|
client_updated_at=article.updated_at,
|
||||||
|
client_content=article.content,
|
||||||
|
client_title=article.title,
|
||||||
|
client_slug=article.slug,
|
||||||
|
last_editor=article.last_editor,
|
||||||
|
client_category=article.category,
|
||||||
|
client_order=article.order
|
||||||
|
).model_dump(mode='json')
|
||||||
|
|
||||||
|
with httpx.Client() as client:
|
||||||
|
for spoke_url in spokes:
|
||||||
|
# Loop Prevention: Skip if the spoke is the origin
|
||||||
|
try:
|
||||||
|
logger.info(f"Broadcasting update to {spoke_url}")
|
||||||
|
response = client.post(
|
||||||
|
spoke_url,
|
||||||
|
json=sync_payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=5.0
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(f"Broadcast to {spoke_url} failed: {response.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error broadcasting to {spoke_url}: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Broadcast error: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def sync_single_article(article_uuid):
|
||||||
|
"""
|
||||||
|
Lógica compartida para sincronizar un artículo con el servidor central.
|
||||||
|
"""
|
||||||
|
logger.info(f"DEBUG: Syncing article {article_uuid}. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' (Type: {type(settings.CENTRAL_SERVER_URL)})")
|
||||||
|
|
||||||
|
if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""':
|
||||||
|
# Enhanced check to catch literal empty quotes if they slip through
|
||||||
|
return
|
||||||
|
|
||||||
|
db = CoreSessionLocal()
|
||||||
|
try:
|
||||||
|
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||||
|
if not article:
|
||||||
|
return
|
||||||
|
|
||||||
|
sync_data = HelpSyncRequest(
|
||||||
|
article_uuid=article.uuid,
|
||||||
|
client_updated_at=article.updated_at,
|
||||||
|
client_content=article.content,
|
||||||
|
client_title=article.title,
|
||||||
|
client_slug=article.slug,
|
||||||
|
last_editor=article.last_editor,
|
||||||
|
client_category=article.category,
|
||||||
|
client_order=article.order
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
|
||||||
|
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.post(
|
||||||
|
settings.CENTRAL_SERVER_URL,
|
||||||
|
json=sync_data.model_dump(mode='json'),
|
||||||
|
headers=headers,
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = HelpSyncResponse(**response.json())
|
||||||
|
if result.status == "UPDATE_REQUIRED":
|
||||||
|
# El servidor tiene una versión más nueva, actualizamos localmente
|
||||||
|
article.content = result.server_content
|
||||||
|
article.title = result.server_title
|
||||||
|
article.slug = result.server_slug
|
||||||
|
article.updated_at = result.server_updated_at
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Article {article.uuid} updated from server.")
|
||||||
|
|
||||||
|
# Download assets if needed
|
||||||
|
from .utils import download_file_from_hub, sync_assets_from_content
|
||||||
|
if result.server_file_url:
|
||||||
|
download_file_from_hub(result.server_file_url)
|
||||||
|
sync_assets_from_content(result.server_content)
|
||||||
|
else:
|
||||||
|
logger.info(f"Article {article.uuid} sync OK: {result.message}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Sync failed for article {article.uuid}: {response.status_code} - {response.text}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error syncing article {article.uuid}: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
@shared_task(name="sync_from_hub_task")
|
||||||
|
def sync_from_hub_task():
|
||||||
|
"""
|
||||||
|
Tarea de POLLING que el Cliente ejecuta periódicamente.
|
||||||
|
Consulta al Hub (CENTRAL_SERVER_URL) por artículos modificados desde
|
||||||
|
la última actualización local.
|
||||||
|
"""
|
||||||
|
if not settings.CENTRAL_SERVER_URL:
|
||||||
|
return
|
||||||
|
|
||||||
|
db = CoreSessionLocal()
|
||||||
|
try:
|
||||||
|
# 1. Obtener la fecha de la última actualización local
|
||||||
|
last_local_update = db.query(func.max(HelpArticle.updated_at)).scalar()
|
||||||
|
if not last_local_update:
|
||||||
|
# Si no hay datos, traer todo desde el principio de los tiempos
|
||||||
|
last_local_update = datetime(2000, 1, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Asegurar timezone awareness
|
||||||
|
if last_local_update.tzinfo is None:
|
||||||
|
last_local_update = last_local_update.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
logger.info(f"Polling Hub for updates since {last_local_update}")
|
||||||
|
|
||||||
|
# 2. Consultar al Hub
|
||||||
|
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
|
||||||
|
# CENTRAL_SERVER_URL es ".../help-center/sync/"
|
||||||
|
# Queremos ".../help-center/modifications/"
|
||||||
|
hub_url = settings.CENTRAL_SERVER_URL.replace("/sync/", "/modifications/")
|
||||||
|
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.get(
|
||||||
|
hub_url,
|
||||||
|
params={"since": last_local_update.isoformat()},
|
||||||
|
headers=headers,
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
articles_data = response.json()
|
||||||
|
if not articles_data:
|
||||||
|
logger.info("No updates found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Found {len(articles_data)} updates from Hub. Applying...")
|
||||||
|
|
||||||
|
# 3. Aplicar actualizaciones
|
||||||
|
for art_data in articles_data:
|
||||||
|
try:
|
||||||
|
# Logic similar to sync_article but simpler (Force Update from Hub)
|
||||||
|
# We assume Hub is Truth in this Polling flow
|
||||||
|
|
||||||
|
# Try to find by UUID
|
||||||
|
local_article = db.query(HelpArticle).filter(HelpArticle.uuid == art_data['uuid']).first()
|
||||||
|
|
||||||
|
# Fallback: find by Slug if UUID doesn't match
|
||||||
|
if not local_article:
|
||||||
|
local_article = db.query(HelpArticle).filter(HelpArticle.slug == art_data['slug']).first()
|
||||||
|
|
||||||
|
server_updated_at = datetime.fromisoformat(art_data['updated_at'])
|
||||||
|
if server_updated_at.tzinfo is None:
|
||||||
|
server_updated_at = server_updated_at.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if not local_article:
|
||||||
|
new_article = HelpArticle(
|
||||||
|
uuid=art_data['uuid'],
|
||||||
|
slug=art_data['slug'],
|
||||||
|
title=art_data['title'],
|
||||||
|
content=art_data['content'],
|
||||||
|
updated_at=server_updated_at,
|
||||||
|
last_editor=art_data['last_editor'],
|
||||||
|
category=art_data.get('category', "General"),
|
||||||
|
order=art_data.get('order', 0)
|
||||||
|
)
|
||||||
|
db.add(new_article)
|
||||||
|
logger.info(f"Created new article: {art_data['slug']}")
|
||||||
|
else:
|
||||||
|
# Update existing article
|
||||||
|
# If UUID changed in Hub but slug is the same, we update UUID too
|
||||||
|
local_article.uuid = art_data['uuid']
|
||||||
|
local_article.slug = art_data['slug']
|
||||||
|
local_article.title = art_data['title']
|
||||||
|
local_article.content = art_data['content']
|
||||||
|
local_article.updated_at = server_updated_at
|
||||||
|
local_article.last_editor = art_data['last_editor']
|
||||||
|
local_article.category = art_data.get('category', "General")
|
||||||
|
local_article.order = art_data.get('order', 0)
|
||||||
|
logger.info(f"Updated article: {art_data['slug']}")
|
||||||
|
|
||||||
|
db.commit() # Commit each article to avoid bulk failure
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error syncing article {art_data.get('slug', 'unknown')}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Download assets after bulk update (Polling)
|
||||||
|
from .utils import download_file_from_hub, sync_assets_from_content
|
||||||
|
for art_data in articles_data:
|
||||||
|
# art_data contains the virtual fields because it was dumped via HelpArticleInDB
|
||||||
|
if "file_url" in art_data and art_data['file_url']:
|
||||||
|
download_file_from_hub(art_data['file_url'])
|
||||||
|
|
||||||
|
sync_assets_from_content(art_data.get('content', ''))
|
||||||
|
|
||||||
|
logger.info("Polling sync completed successfully.")
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.error(f"Polling failed: {response.status_code} - {response.text}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in sync_from_hub_task: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
76
backend/api/v1/modules/core/help_center/utils.py
Normal file
76
backend/api/v1/modules/core/help_center/utils.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def download_file_from_hub(relative_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Downloads a file from the Hub to the local storage.
|
||||||
|
relative_path: e.g., 'uploads/help/pdfs/myfile.pdf' or '/api/uploads/help/image.png'
|
||||||
|
"""
|
||||||
|
if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""':
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Clean the path
|
||||||
|
clean_path = relative_path.replace("/api/uploads/", "uploads/")
|
||||||
|
if clean_path.startswith("/"):
|
||||||
|
clean_path = clean_path[1:]
|
||||||
|
|
||||||
|
# Check if it starts with uploads
|
||||||
|
if not clean_path.startswith("uploads/"):
|
||||||
|
# If it doesn't start with uploads, it might just be the filename or a subpath
|
||||||
|
# We assume it's relative to /app/
|
||||||
|
pass
|
||||||
|
|
||||||
|
local_path = Path(clean_path)
|
||||||
|
if local_path.exists():
|
||||||
|
logger.info(f"File {clean_path} already exists, skipping download.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Resolve Hub Base URL
|
||||||
|
# CENTRAL_SERVER_URL is usually http://hub:8000/api/v1/core/help-center/sync/
|
||||||
|
# We want http://hub:8000/api/
|
||||||
|
base_url = settings.CENTRAL_SERVER_URL.split("/v1/")[0]
|
||||||
|
# The file in backend is served usually under /api/uploads/...
|
||||||
|
# But clean_path is just "uploads/...". So the Hub route is base_url + "/" + clean_path
|
||||||
|
hub_file_url = f"{base_url}/{clean_path}"
|
||||||
|
|
||||||
|
logger.info(f"Downloading asset from Hub: {hub_file_url} -> {local_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.get(hub_file_url, timeout=30.0)
|
||||||
|
if response.status_code == 200:
|
||||||
|
with open(local_path, "wb") as f:
|
||||||
|
f.write(response.content)
|
||||||
|
logger.info(f"Successfully downloaded {clean_path}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to download {clean_path}: Status {response.status_code} URL: {hub_file_url}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error downloading {clean_path}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def sync_assets_from_content(content: str):
|
||||||
|
"""
|
||||||
|
Parses markdown content for image URLs and downloads them if they are local references.
|
||||||
|
Example: 
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Regex for markdown images: 
|
||||||
|
image_pattern = r'!\[.*?\]\((/api/uploads/.*?)\)'
|
||||||
|
matches = re.findall(image_pattern, content)
|
||||||
|
|
||||||
|
for asset_url in matches:
|
||||||
|
download_file_from_hub(asset_url)
|
||||||
@@ -5,6 +5,7 @@ from .tenants.routes import router as tenants_router
|
|||||||
from .user_tenant.routes import router as user_tenant_router
|
from .user_tenant.routes import router as user_tenant_router
|
||||||
from .users.routes import router as users_router
|
from .users.routes import router as users_router
|
||||||
from .dashboard.routes import router as dashboard_router
|
from .dashboard.routes import router as dashboard_router
|
||||||
|
from .help_center.routes import router as help_center_router
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -16,3 +17,4 @@ router.include_router(users_router, prefix="/core", tags=["core / users"])
|
|||||||
router.include_router(licenses_router, prefix="/core", tags=["core / licenses"])
|
router.include_router(licenses_router, prefix="/core", tags=["core / licenses"])
|
||||||
router.include_router(permissions_router, prefix="/core", tags=["core / permissions"])
|
router.include_router(permissions_router, prefix="/core", tags=["core / permissions"])
|
||||||
router.include_router(dashboard_router, prefix="/core", tags=["core / dashboard"])
|
router.include_router(dashboard_router, prefix="/core", tags=["core / dashboard"])
|
||||||
|
router.include_router(help_center_router, prefix="/core", tags=["core / help-center"])
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem # noqa: F401
|
|||||||
from api.v1.modules.a76.items.models import LineItem # noqa: F401
|
from api.v1.modules.a76.items.models import LineItem # noqa: F401
|
||||||
|
|
||||||
valkey_url = os.getenv("VALKEY_URL", "redis://valkey:6379/0")
|
valkey_url = os.getenv("VALKEY_URL", "redis://valkey:6379/0")
|
||||||
|
print(f"DEBUG: Celery Broker URL: {valkey_url}")
|
||||||
|
|
||||||
|
# Configurar broker y backend explícitamente en el constructor
|
||||||
celery_app = Celery(
|
celery_app = Celery(
|
||||||
"anexo76_tasks",
|
"anexo76_tasks",
|
||||||
broker=valkey_url,
|
broker=valkey_url,
|
||||||
backend=valkey_url,
|
backend=valkey_url,
|
||||||
|
)
|
||||||
|
celery_app.set_default()
|
||||||
|
|
||||||
|
celery_app.conf.update(
|
||||||
include=[
|
include=[
|
||||||
"api.v1.modules.a76.reports.importacion.facturas.task",
|
"api.v1.modules.a76.reports.importacion.facturas.task",
|
||||||
"api.v1.modules.a76.reports.importacion.consolidados.task",
|
"api.v1.modules.a76.reports.importacion.consolidados.task",
|
||||||
@@ -23,6 +29,7 @@ celery_app = Celery(
|
|||||||
"api.v1.modules.a76.reports.exportacion.transmission.MAINX30.task",
|
"api.v1.modules.a76.reports.exportacion.transmission.MAINX30.task",
|
||||||
"api.v1.modules.a76.reports.importacion.transmission.temporal.MAINX30.task",
|
"api.v1.modules.a76.reports.importacion.transmission.temporal.MAINX30.task",
|
||||||
"api.v1.modules.a76.reports.importacion.transmission.definitive.MAINX30.task"
|
"api.v1.modules.a76.reports.importacion.transmission.definitive.MAINX30.task"
|
||||||
|
"api.v1.modules.core.help_center.tasks"
|
||||||
] # Ruta al módulo donde están las tareas
|
] # Ruta al módulo donde están las tareas
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,5 +43,12 @@ celery_app.conf.update(
|
|||||||
enable_utc=True,
|
enable_utc=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
celery_app.conf.beat_schedule = {
|
||||||
|
"sync-from-hub-every-minute": {
|
||||||
|
"task": "sync_from_hub_task",
|
||||||
|
"schedule": 60.0, # Run every 60 seconds
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
celery_app.start()
|
celery_app.start()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Configuración centralizada de la aplicación usando Pydantic Settings
|
|||||||
import os
|
import os
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
from pydantic import field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +40,11 @@ class Settings(BaseSettings):
|
|||||||
ALGORITHM: str = "HS256"
|
ALGORITHM: str = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
|
||||||
|
# Synchronization
|
||||||
|
SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production"
|
||||||
|
CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/"
|
||||||
|
SPOKE_URLS: str = "" # Comma separated list of Spoke URLs for Broadcast (Hub only)
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||||
|
|
||||||
@@ -65,6 +71,13 @@ class Settings(BaseSettings):
|
|||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def strip_quotes(cls, v: str) -> str:
|
||||||
|
if v:
|
||||||
|
return v.strip().strip('"').strip("'")
|
||||||
|
return v
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def core_database_url(self) -> str:
|
def core_database_url(self) -> str:
|
||||||
"""URL de conexión a la base de datos core"""
|
"""URL de conexión a la base de datos core"""
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
|||||||
"/api/health",
|
"/api/health",
|
||||||
"/api/",
|
"/api/",
|
||||||
"/uploads",
|
"/uploads",
|
||||||
|
"/api/v1/core/help-center",
|
||||||
]
|
]
|
||||||
|
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
@@ -86,6 +87,7 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
|||||||
"/api/v1/status",
|
"/api/v1/status",
|
||||||
"/api/health",
|
"/api/health",
|
||||||
"/api/",
|
"/api/",
|
||||||
|
"/api/v1/core/help-center",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Verificar si la ruta está exenta (comparación exacta o prefijo)
|
# Verificar si la ruta está exenta (comparación exacta o prefijo)
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ def has_role(required_role: str):
|
|||||||
user_roles = current_user.get("realm_access", {}).get("roles", [])
|
user_roles = current_user.get("realm_access", {}).get("roles", [])
|
||||||
|
|
||||||
if required_role not in user_roles:
|
if required_role not in user_roles:
|
||||||
|
logger.warning(f"Role denied. Required: {required_role}. User actually has: {user_roles}")
|
||||||
|
# Also check client roles as a debug fallback
|
||||||
|
client_roles = current_user.get("resource_access", {})
|
||||||
|
logger.warning(f"User client roles: {client_roles}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail=f"User does not have required role: {required_role}",
|
detail=f"User does not have required role: {required_role}",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Anexo76 - Aplicación SaaS para gestión de comercio exterior
|
Anexo76 - Aplicación SaaS para gestión de comercio exterior
|
||||||
Backend API con FastAPI + Keycloak + SQLAlchemy
|
Backend API con FastAPI + Keycloak + SQLAlchemy
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -75,6 +75,7 @@ from api.v1.modules.a76.audit_log.events import register_audit_listeners
|
|||||||
|
|
||||||
# Core Modules (Secondary)
|
# Core Modules (Secondary)
|
||||||
|
|
||||||
|
import core.celery_app # Initialize Celery App
|
||||||
from api.v1.router import router as api_v1_router
|
from api.v1.router import router as api_v1_router
|
||||||
from core.config import settings
|
from core.config import settings
|
||||||
from core.database import init_db
|
from core.database import init_db
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ services:
|
|||||||
- backend-net
|
- backend-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres -d anexo76_core || exit 1"]
|
test: [ "CMD-SHELL", "pg_isready -U postgres -d anexo76_core || exit 1" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -37,7 +37,7 @@ services:
|
|||||||
|
|
||||||
# PostgreSQL - Base de datos Keycloak
|
# PostgreSQL - Base de datos Keycloak
|
||||||
postgres-keycloak:
|
postgres-keycloak:
|
||||||
image: postgres:18-alpine
|
image: postgres:16-alpine
|
||||||
container_name: anexo76-postgres-keycloak
|
container_name: anexo76-postgres-keycloak
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: keycloak
|
POSTGRES_DB: keycloak
|
||||||
@@ -54,7 +54,7 @@ services:
|
|||||||
- backend-net
|
- backend-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres -d keycloak || exit 1"]
|
test: [ "CMD-SHELL", "pg_isready -U postgres -d keycloak || exit 1" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -80,10 +80,10 @@ services:
|
|||||||
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
|
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
|
||||||
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
|
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
|
||||||
KC_DB: postgres
|
KC_DB: postgres
|
||||||
KC_DB_URL_HOST: postgres-keycloak
|
KC_DB_URL_HOST: anexo76-postgres-keycloak
|
||||||
KC_DB_URL_PORT: "5432"
|
KC_DB_URL_PORT: "5432"
|
||||||
KC_DB_URL_DATABASE: keycloak
|
KC_DB_URL_DATABASE: keycloak
|
||||||
KC_DB_URL: jdbc:postgresql://postgres-keycloak:5432/keycloak
|
KC_DB_URL: jdbc:postgresql://anexo76-postgres-keycloak:5432/keycloak
|
||||||
KC_DB_USERNAME: postgres
|
KC_DB_USERNAME: postgres
|
||||||
KC_DB_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres}
|
KC_DB_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres}
|
||||||
KC_DB_SCHEMA: public
|
KC_DB_SCHEMA: public
|
||||||
@@ -96,12 +96,12 @@ services:
|
|||||||
KC_HEALTH_ENABLED: "true"
|
KC_HEALTH_ENABLED: "true"
|
||||||
KC_METRICS_ENABLED: "true"
|
KC_METRICS_ENABLED: "true"
|
||||||
KC_HOSTNAME_PATH: /kcauth
|
KC_HOSTNAME_PATH: /kcauth
|
||||||
KC_LOG_LEVEL: INFO
|
KC_LOG_LEVEL: INFO
|
||||||
JAVA_OPTS_APPEND: "-Xms256m -Xmx512m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Djava.net.preferIPv4Stack=true"
|
JAVA_OPTS_APPEND: "-Xms256m -Xmx512m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Djava.net.preferIPv4Stack=true"
|
||||||
command:
|
command:
|
||||||
- start
|
- start
|
||||||
- --db=postgres
|
- --db=postgres
|
||||||
- --db-url-host=postgres-keycloak
|
- --db-url-host=anexo76-postgres-keycloak
|
||||||
- --http-relative-path=/kcauth
|
- --http-relative-path=/kcauth
|
||||||
- --db-url-port=5432
|
- --db-url-port=5432
|
||||||
- --db-url-database=keycloak
|
- --db-url-database=keycloak
|
||||||
@@ -123,7 +123,19 @@ services:
|
|||||||
- backend-net
|
- backend-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r\nhost: 127.0.0.1\r\nConnection: close\r\n\r\n' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"]
|
test:
|
||||||
|
[
|
||||||
|
"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
|
||||||
|
|
||||||
|
\r
|
||||||
|
|
||||||
|
' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 30
|
retries: 30
|
||||||
@@ -162,6 +174,13 @@ services:
|
|||||||
- SITAR_API_URL=${SITAR_API_URL}
|
- SITAR_API_URL=${SITAR_API_URL}
|
||||||
- SITAR_API_USER=${SITAR_API_USER}
|
- SITAR_API_USER=${SITAR_API_USER}
|
||||||
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
|
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
|
||||||
|
- EXTERNAL_API_URL=${EXTERNAL_API_URL}
|
||||||
|
- EXTERNAL_API_USER=${EXTERNAL_API_USER}
|
||||||
|
- EXTERNAL_API_PASSWORD=${EXTERNAL_API_PASSWORD}
|
||||||
|
- 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}
|
||||||
|
- SPOKE_URLS=${SPOKE_URLS:-""}
|
||||||
ports:
|
ports:
|
||||||
- "3467:8000"
|
- "3467:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -176,24 +195,10 @@ services:
|
|||||||
- backend-net
|
- backend-net
|
||||||
- frontend-net
|
- frontend-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
entrypoint: ["/entrypoint.sh"]
|
entrypoint: [ "/entrypoint.sh" ]
|
||||||
command:
|
command: [ "gunicorn", "main:app", "-k", "uvicorn.workers.UvicornWorker", "-w", "${WEB_CONCURRENCY:-1}", "-b", "0.0.0.0:8000", "--log-level", "info", "--forwarded-allow-ips", "*" ]
|
||||||
[
|
|
||||||
"gunicorn",
|
|
||||||
"main:app",
|
|
||||||
"-k",
|
|
||||||
"uvicorn.workers.UvicornWorker",
|
|
||||||
"-w",
|
|
||||||
"${WEB_CONCURRENCY:-1}",
|
|
||||||
"-b",
|
|
||||||
"0.0.0.0:8000",
|
|
||||||
"--log-level",
|
|
||||||
"info",
|
|
||||||
"--forwarded-allow-ips",
|
|
||||||
"*"
|
|
||||||
]
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1"]
|
test: [ "CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1" ]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -216,7 +221,15 @@ services:
|
|||||||
container_name: worker
|
container_name: worker
|
||||||
command: celery -A core.celery_app worker --loglevel=info
|
command: celery -A core.celery_app worker --loglevel=info
|
||||||
environment:
|
environment:
|
||||||
- VALKEY_URL=redis://valkey:6379/0
|
- 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}
|
||||||
|
- 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:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
- valkey
|
- valkey
|
||||||
@@ -252,7 +265,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
entrypoint: ["/frontend-entrypoint.sh"]
|
entrypoint: [ "/frontend-entrypoint.sh" ]
|
||||||
volumes:
|
volumes:
|
||||||
- ./scripts/frontend-entrypoint.sh:/frontend-entrypoint.sh:ro
|
- ./scripts/frontend-entrypoint.sh:/frontend-entrypoint.sh:ro
|
||||||
networks:
|
networks:
|
||||||
@@ -260,9 +273,9 @@ services:
|
|||||||
- backend-net
|
- backend-net
|
||||||
- auth-net
|
- auth-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["pnpm", "start"]
|
command: [ "pnpm", "start" ]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5173/ || exit 1"]
|
test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5173/ || exit 1" ]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ services:
|
|||||||
"CMD-SHELL",
|
"CMD-SHELL",
|
||||||
"exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r
|
"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
|
Host: localhost\r
|
||||||
|
|
||||||
Connection: close\r
|
Connection: close\r
|
||||||
|
|
||||||
@@ -178,6 +178,13 @@ services:
|
|||||||
- SITAR_API_URL=${SITAR_API_URL}
|
- SITAR_API_URL=${SITAR_API_URL}
|
||||||
- SITAR_API_USER=${SITAR_API_USER}
|
- SITAR_API_USER=${SITAR_API_USER}
|
||||||
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
|
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
|
||||||
|
- EXTERNAL_API_URL=${EXTERNAL_API_URL}
|
||||||
|
- EXTERNAL_API_USER=${EXTERNAL_API_USER}
|
||||||
|
- EXTERNAL_API_PASSWORD=${EXTERNAL_API_PASSWORD}
|
||||||
|
- 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}
|
||||||
|
- SPOKE_URLS=${SPOKE_URLS:-""}
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -234,6 +241,7 @@ services:
|
|||||||
- KEYCLOAK_REALM=${KEYCLOAK_REALM:-master}
|
- KEYCLOAK_REALM=${KEYCLOAK_REALM:-master}
|
||||||
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
|
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
|
||||||
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9}
|
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9}
|
||||||
|
- VITE_HUB_MODE=${VITE_HUB_MODE:-true}
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -266,6 +274,7 @@ services:
|
|||||||
memory: 1G
|
memory: 1G
|
||||||
reservations:
|
reservations:
|
||||||
memory: 512M
|
memory: 512M
|
||||||
|
|
||||||
# celery
|
# celery
|
||||||
celery_worker:
|
celery_worker:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
@@ -276,6 +285,10 @@ services:
|
|||||||
- ENVIRONMENT=${ENVIRONMENT:-development}
|
- ENVIRONMENT=${ENVIRONMENT:-development}
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- PYTHONDONTWRITEBYTECODE=1
|
- PYTHONDONTWRITEBYTECODE=1
|
||||||
|
- 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}
|
||||||
|
- SPOKE_URLS=${SPOKE_URLS:-""}
|
||||||
- CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76}
|
- CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76}
|
||||||
- CORE_DB_PORT=${CORE_DB_PORT:-5432}
|
- CORE_DB_PORT=${CORE_DB_PORT:-5432}
|
||||||
- CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core}
|
- CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core}
|
||||||
@@ -289,6 +302,34 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
- valkey
|
- valkey
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- backend_cache:/app/__pycache__
|
||||||
|
- backend_uploads:/app/uploads
|
||||||
|
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}
|
||||||
|
- 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
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- backend_cache:/app/__pycache__
|
||||||
|
- backend_uploads:/app/uploads
|
||||||
networks:
|
networks:
|
||||||
- backend-net
|
- backend-net
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ RUN npm config set strict-ssl false
|
|||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
# Instalar dependencias
|
# Instalar dependencias
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install
|
||||||
|
|
||||||
# Copiar código
|
# Copiar código
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -57,8 +57,12 @@
|
|||||||
"vitest-browser-svelte": "^1.1.0"
|
"vitest-browser-svelte": "^1.1.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/dompurify": "^3.2.0",
|
||||||
|
"@types/marked": "^6.0.0",
|
||||||
|
"dompurify": "^3.0.9",
|
||||||
"keycloak-js": "^26.2.1",
|
"keycloak-js": "^26.2.1",
|
||||||
"lucide-svelte": "^0.553.0",
|
"lucide-svelte": "^0.553.0",
|
||||||
|
"marked": "^12.0.0",
|
||||||
"svelte-sonner": "^1.0.7"
|
"svelte-sonner": "^1.0.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
67
frontend/pnpm-lock.yaml
generated
67
frontend/pnpm-lock.yaml
generated
@@ -8,12 +8,24 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@types/dompurify':
|
||||||
|
specifier: ^3.2.0
|
||||||
|
version: 3.2.0
|
||||||
|
'@types/marked':
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
|
dompurify:
|
||||||
|
specifier: ^3.0.9
|
||||||
|
version: 3.3.1
|
||||||
keycloak-js:
|
keycloak-js:
|
||||||
specifier: ^26.2.1
|
specifier: ^26.2.1
|
||||||
version: 26.2.1
|
version: 26.2.1
|
||||||
lucide-svelte:
|
lucide-svelte:
|
||||||
specifier: ^0.553.0
|
specifier: ^0.553.0
|
||||||
version: 0.553.0(svelte@5.40.2)
|
version: 0.553.0(svelte@5.40.2)
|
||||||
|
marked:
|
||||||
|
specifier: ^12.0.0
|
||||||
|
version: 12.0.2
|
||||||
svelte-sonner:
|
svelte-sonner:
|
||||||
specifier: ^1.0.7
|
specifier: ^1.0.7
|
||||||
version: 1.0.7(svelte@5.40.2)
|
version: 1.0.7(svelte@5.40.2)
|
||||||
@@ -505,56 +517,67 @@ packages:
|
|||||||
resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==}
|
resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.52.4':
|
'@rollup/rollup-linux-arm-musleabihf@4.52.4':
|
||||||
resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==}
|
resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.52.4':
|
'@rollup/rollup-linux-arm64-gnu@4.52.4':
|
||||||
resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==}
|
resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.52.4':
|
'@rollup/rollup-linux-arm64-musl@4.52.4':
|
||||||
resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==}
|
resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.52.4':
|
'@rollup/rollup-linux-loong64-gnu@4.52.4':
|
||||||
resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==}
|
resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.52.4':
|
'@rollup/rollup-linux-ppc64-gnu@4.52.4':
|
||||||
resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==}
|
resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.52.4':
|
'@rollup/rollup-linux-riscv64-gnu@4.52.4':
|
||||||
resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==}
|
resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.52.4':
|
'@rollup/rollup-linux-riscv64-musl@4.52.4':
|
||||||
resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==}
|
resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.52.4':
|
'@rollup/rollup-linux-s390x-gnu@4.52.4':
|
||||||
resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==}
|
resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.52.4':
|
'@rollup/rollup-linux-x64-gnu@4.52.4':
|
||||||
resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==}
|
resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.52.4':
|
'@rollup/rollup-linux-x64-musl@4.52.4':
|
||||||
resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==}
|
resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-openharmony-arm64@4.52.4':
|
'@rollup/rollup-openharmony-arm64@4.52.4':
|
||||||
resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==}
|
resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==}
|
||||||
@@ -675,24 +698,28 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.14':
|
'@tailwindcss/oxide-linux-arm64-musl@4.1.14':
|
||||||
resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==}
|
resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.14':
|
'@tailwindcss/oxide-linux-x64-gnu@4.1.14':
|
||||||
resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==}
|
resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-musl@4.1.14':
|
'@tailwindcss/oxide-linux-x64-musl@4.1.14':
|
||||||
resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==}
|
resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-wasm32-wasi@4.1.14':
|
'@tailwindcss/oxide-wasm32-wasi@4.1.14':
|
||||||
resolution: {integrity: sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==}
|
resolution: {integrity: sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==}
|
||||||
@@ -758,18 +785,29 @@ packages:
|
|||||||
'@types/deep-eql@4.0.2':
|
'@types/deep-eql@4.0.2':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
|
'@types/dompurify@3.2.0':
|
||||||
|
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
|
||||||
|
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/marked@6.0.0':
|
||||||
|
resolution: {integrity: sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==}
|
||||||
|
deprecated: This is a stub types definition. marked provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/node@20.19.22':
|
'@types/node@20.19.22':
|
||||||
resolution: {integrity: sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==}
|
resolution: {integrity: sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==}
|
||||||
|
|
||||||
'@types/resolve@1.20.2':
|
'@types/resolve@1.20.2':
|
||||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.46.1':
|
'@typescript-eslint/eslint-plugin@8.46.1':
|
||||||
resolution: {integrity: sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==}
|
resolution: {integrity: sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1054,6 +1092,9 @@ packages:
|
|||||||
dom-accessibility-api@0.5.16:
|
dom-accessibility-api@0.5.16:
|
||||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||||
|
|
||||||
|
dompurify@3.3.1:
|
||||||
|
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -1368,24 +1409,28 @@ packages:
|
|||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.30.1:
|
lightningcss-linux-arm64-musl@1.30.1:
|
||||||
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
|
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.30.1:
|
lightningcss-linux-x64-gnu@1.30.1:
|
||||||
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
|
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.30.1:
|
lightningcss-linux-x64-musl@1.30.1:
|
||||||
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
|
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.30.1:
|
lightningcss-win32-arm64-msvc@1.30.1:
|
||||||
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
|
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
|
||||||
@@ -1432,6 +1477,11 @@ packages:
|
|||||||
magic-string@0.30.19:
|
magic-string@0.30.19:
|
||||||
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
|
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
|
||||||
|
|
||||||
|
marked@12.0.2:
|
||||||
|
resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
merge2@1.4.1:
|
merge2@1.4.1:
|
||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -2547,16 +2597,27 @@ snapshots:
|
|||||||
|
|
||||||
'@types/deep-eql@4.0.2': {}
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
|
'@types/dompurify@3.2.0':
|
||||||
|
dependencies:
|
||||||
|
dompurify: 3.3.1
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/marked@6.0.0':
|
||||||
|
dependencies:
|
||||||
|
marked: 12.0.2
|
||||||
|
|
||||||
'@types/node@20.19.22':
|
'@types/node@20.19.22':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
'@types/resolve@1.20.2': {}
|
'@types/resolve@1.20.2': {}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.1
|
'@eslint-community/regexpp': 4.12.1
|
||||||
@@ -2853,6 +2914,10 @@ snapshots:
|
|||||||
|
|
||||||
dom-accessibility-api@0.5.16: {}
|
dom-accessibility-api@0.5.16: {}
|
||||||
|
|
||||||
|
dompurify@3.3.1:
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -3212,6 +3277,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
marked@12.0.2: {}
|
||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
|
|
||||||
micromatch@4.0.8:
|
micromatch@4.0.8:
|
||||||
|
|||||||
125
frontend/src/lib/api/help.ts
Normal file
125
frontend/src/lib/api/help.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { getToken, authStore } from '$lib/auth';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
const api_url = import.meta.env.VITE_API_URL;
|
||||||
|
const BASE_URL = `${api_url.endsWith('/') ? api_url : api_url + '/'}v1/core/help-center`;
|
||||||
|
|
||||||
|
function getAuthToken(): string | null {
|
||||||
|
// 1. First try getToken() which checks Keycloak and localStorage
|
||||||
|
let token = getToken();
|
||||||
|
|
||||||
|
// 2. If somehow empty, explicitly check authStore value
|
||||||
|
if (!token) {
|
||||||
|
const auth = get(authStore);
|
||||||
|
token = auth.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeaders() {
|
||||||
|
const token = getAuthToken();
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelpArticle {
|
||||||
|
uuid: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
updated_at: string;
|
||||||
|
last_editor: string;
|
||||||
|
category?: string;
|
||||||
|
order?: number;
|
||||||
|
content_type: string;
|
||||||
|
file_url?: string;
|
||||||
|
file_size?: number;
|
||||||
|
mime_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const helpApi = {
|
||||||
|
async listArticles(): Promise<HelpArticle[]> {
|
||||||
|
const response = await fetch(`${BASE_URL}/articles/`, { headers: getHeaders() });
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch articles');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getArticle(uuid: string): Promise<HelpArticle> {
|
||||||
|
const response = await fetch(`${BASE_URL}/articles/${uuid}/`, { headers: getHeaders() });
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch article');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateArticle(uuid: string, data: Partial<HelpArticle>): Promise<HelpArticle> {
|
||||||
|
const response = await fetch(`${BASE_URL}/articles/${uuid}/`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (response.status === 403) throw new Error('No tienes permisos para editar artículos (Requiere rol Admin)');
|
||||||
|
if (!response.ok) throw new Error('Error al guardar cambios');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async createArticle(data: Partial<HelpArticle>): Promise<HelpArticle> {
|
||||||
|
const response = await fetch(`${BASE_URL}/articles/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (response.status === 403) throw new Error('No tienes permisos para crear artículos (Requiere rol Admin)');
|
||||||
|
if (!response.ok) throw new Error('Error al crear el artículo');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteArticle(uuid: string): Promise<void> {
|
||||||
|
const response = await fetch(`${BASE_URL}/articles/${uuid}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (response.status === 403) throw new Error('No tienes permisos para eliminar (Requiere rol Admin)');
|
||||||
|
if (!response.ok) throw new Error('Error al eliminar');
|
||||||
|
},
|
||||||
|
|
||||||
|
async triggerSync(): Promise<void> {
|
||||||
|
// Opcional: endpoint para forzar sync desde UI si es necesario
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadImage(file: File): Promise<{ url: string }> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await fetch(`${BASE_URL}/upload-image/`, {
|
||||||
|
method: 'POST',
|
||||||
|
// No Content-Type header for FormData, browser sets it with boundary
|
||||||
|
headers: {
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
if (response.status === 403) throw new Error('No tienes permisos para subir imágenes (Requiere rol Admin)');
|
||||||
|
if (!response.ok) throw new Error('Error al subir imagen');
|
||||||
|
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 token = getAuthToken();
|
||||||
|
const response = await fetch(`${BASE_URL}/upload-asset/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
if (response.status === 403) throw new Error('No tienes permisos para subir archivos (Requiere rol Admin)');
|
||||||
|
if (!response.ok) throw new Error('Error al subir archivo');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
229
frontend/src/lib/components/help/HelpDrawer.svelte
Normal file
229
frontend/src/lib/components/help/HelpDrawer.svelte
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { authStore, currentUser } from '$lib/auth';
|
||||||
|
import * as Sheet from '$lib/components/ui/sheet';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { HelpCircle, Edit2, Save, X, ChevronLeft, Plus } from 'lucide-svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { helpApi, type HelpArticle } from '$lib/api/help';
|
||||||
|
import { helpStore } from '$lib/stores/help.svelte';
|
||||||
|
|
||||||
|
// Nota: Estas librerías deben ser instaladas: npm install marked dompurify @types/dompurify
|
||||||
|
// Si no están, fallará el import. El usuario debe instalarlas.
|
||||||
|
// import { marked } from 'marked';
|
||||||
|
// import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
let articles = $state<HelpArticle[]>([]);
|
||||||
|
let selectedArticle = $state<HelpArticle | null>(null);
|
||||||
|
let isEditing = $state(false);
|
||||||
|
let editContent = $state('');
|
||||||
|
let editTitle = $state('');
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let isCreating = $state(false);
|
||||||
|
|
||||||
|
// Temporalmente habilitado para todos los usuarios por petición
|
||||||
|
const isAdmin = $derived(true);
|
||||||
|
|
||||||
|
async function loadArticles() {
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
articles = await helpApi.listArticles();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('Error al cargar artículos de ayuda');
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectArticle(article: HelpArticle) {
|
||||||
|
selectedArticle = article;
|
||||||
|
isEditing = false;
|
||||||
|
isCreating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
if (!selectedArticle) return;
|
||||||
|
editContent = selectedArticle.content;
|
||||||
|
editTitle = selectedArticle.title;
|
||||||
|
isEditing = true;
|
||||||
|
isCreating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCreate() {
|
||||||
|
editContent = '# Nuevo Artículo\nEscribe el contenido aquí...';
|
||||||
|
editTitle = '';
|
||||||
|
isEditing = true;
|
||||||
|
isCreating = true;
|
||||||
|
selectedArticle = {
|
||||||
|
uuid: '',
|
||||||
|
slug: '',
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
updated_at: '',
|
||||||
|
last_editor: ''
|
||||||
|
}; // Mock for UI
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSlug(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w ]+/g, '')
|
||||||
|
.replace(/ +/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveChanges() {
|
||||||
|
try {
|
||||||
|
if (isCreating) {
|
||||||
|
const slug = generateSlug(editTitle);
|
||||||
|
const newArticle = await helpApi.createArticle({
|
||||||
|
title: editTitle,
|
||||||
|
content: editContent,
|
||||||
|
slug: slug,
|
||||||
|
last_editor: $currentUser?.username || 'unknown'
|
||||||
|
});
|
||||||
|
selectedArticle = newArticle;
|
||||||
|
toast.success('Capítulo creado con éxito');
|
||||||
|
} else if (selectedArticle) {
|
||||||
|
const updated = await helpApi.updateArticle(selectedArticle.uuid, {
|
||||||
|
content: editContent,
|
||||||
|
title: editTitle,
|
||||||
|
last_editor: $currentUser?.username || 'unknown'
|
||||||
|
});
|
||||||
|
selectedArticle = updated;
|
||||||
|
toast.success('Cambios guardados y sincronizados');
|
||||||
|
}
|
||||||
|
isEditing = false;
|
||||||
|
isCreating = false;
|
||||||
|
loadArticles();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(isCreating ? 'Error al crear artículo' : 'Error al guardar cambios');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadArticles();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple markdown renderer fallback if marked is not available
|
||||||
|
function renderMarkdown(content: string) {
|
||||||
|
// En una implementación real, usar marked + DOMPurify
|
||||||
|
// return DOMPurify.sanitize(marked.parse(content));
|
||||||
|
|
||||||
|
// Fallback ultra-básico para demostración
|
||||||
|
return content
|
||||||
|
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||||
|
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||||
|
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||||
|
.replace(/\*\*(.*)\*\*/gim, '<b>$1</b>')
|
||||||
|
.replace(/\*(.*)\*/gim, '<i>$1</i>')
|
||||||
|
.replace(/\n/gim, '<br />');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sheet.Root bind:open={helpStore.isOpen}>
|
||||||
|
<Sheet.Trigger>
|
||||||
|
<button
|
||||||
|
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
||||||
|
aria-label="Ayuda"
|
||||||
|
>
|
||||||
|
<HelpCircle size={28} />
|
||||||
|
</button>
|
||||||
|
</Sheet.Trigger>
|
||||||
|
<Sheet.Content side="right" class="w-[400px] sm:w-[540px]">
|
||||||
|
<Sheet.Header>
|
||||||
|
<Sheet.Title class="flex items-center gap-2">
|
||||||
|
{#if selectedArticle}
|
||||||
|
<Button variant="ghost" size="icon" onclick={() => (selectedArticle = null)}>
|
||||||
|
<ChevronLeft size={20} />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
Base de Conocimientos
|
||||||
|
</Sheet.Title>
|
||||||
|
</Sheet.Header>
|
||||||
|
|
||||||
|
<div class="mt-6 flex h-[calc(100vh-120px)] flex-col">
|
||||||
|
{#if !selectedArticle}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-medium">Artículos Disponibles</h3>
|
||||||
|
{#if isAdmin}
|
||||||
|
<Button variant="outline" size="sm" onclick={startCreate}>
|
||||||
|
<Plus size={16} class="mr-2" /> Agregar Nuevo
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if isLoading}
|
||||||
|
<p class="py-10 text-center text-sm text-muted-foreground">Cargando...</p>
|
||||||
|
{:else if articles.length === 0}
|
||||||
|
<p class="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
No hay artículos de ayuda disponibles.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="grid gap-2">
|
||||||
|
{#each articles as article}
|
||||||
|
<button
|
||||||
|
onclick={() => selectArticle(article)}
|
||||||
|
class="flex flex-col items-start rounded-lg border p-4 text-left transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<span class="font-semibold">{article.title}</span>
|
||||||
|
<span class="text-xs text-muted-foreground"
|
||||||
|
>Última edición: {new Date(article.updated_at).toLocaleDateString()}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-1 flex-col gap-4">
|
||||||
|
{#if isEditing}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<input
|
||||||
|
bind:value={editTitle}
|
||||||
|
class="w-full rounded-md border bg-transparent p-2 text-xl font-bold"
|
||||||
|
placeholder="Título del artículo"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
bind:value={editContent}
|
||||||
|
class="min-h-[400px] w-full flex-1 rounded-md border bg-transparent p-4 font-mono text-sm focus:ring-1 focus:ring-primary focus:outline-none"
|
||||||
|
placeholder="Escribe en Markdown..."
|
||||||
|
></textarea>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
isEditing = false;
|
||||||
|
if (isCreating) selectedArticle = null;
|
||||||
|
isCreating = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} class="mr-2" /> Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onclick={saveChanges}>
|
||||||
|
<Save size={16} class="mr-2" /> Guardar Cambios
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-2xl font-bold">{selectedArticle.title}</h2>
|
||||||
|
{#if isAdmin}
|
||||||
|
<Button variant="outline" size="sm" onclick={startEdit}>
|
||||||
|
<Edit2 size={16} class="mr-2" /> Editar
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="prose prose-sm max-w-none border-t pt-4 dark:prose-invert">
|
||||||
|
{@html renderMarkdown(selectedArticle.content)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Sheet.Content>
|
||||||
|
</Sheet.Root>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Estilos adicionales si son necesarios */
|
||||||
|
</style>
|
||||||
@@ -514,7 +514,7 @@ export function getSidebarData(): SidebarData {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: m["sidebar.reference_data.ayuda"](),
|
name: m["sidebar.reference_data.ayuda"](),
|
||||||
url: "#",
|
url: "/dashboard/help-center",
|
||||||
icon: Frame,
|
icon: Frame,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from "svelte";
|
import { tick } from 'svelte';
|
||||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||||
import { useSidebar } from "$lib/components/ui/sidebar/context.svelte.js";
|
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
|
||||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
|
||||||
import FolderIcon from "@lucide/svelte/icons/folder";
|
import FolderIcon from '@lucide/svelte/icons/folder';
|
||||||
import ForwardIcon from "@lucide/svelte/icons/forward";
|
import ForwardIcon from '@lucide/svelte/icons/forward';
|
||||||
import Trash2Icon from "@lucide/svelte/icons/trash-2";
|
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||||
|
import { helpStore } from '$lib/stores/help.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
projects,
|
projects
|
||||||
}: {
|
}: {
|
||||||
projects: {
|
projects: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -58,8 +59,8 @@
|
|||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
class="w-48 rounded-lg"
|
class="w-48 rounded-lg"
|
||||||
side={sidebar.isMobile ? "bottom" : "right"}
|
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||||
align={sidebar.isMobile ? "end" : "start"}
|
align={sidebar.isMobile ? 'end' : 'start'}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item>
|
<DropdownMenu.Item>
|
||||||
<FolderIcon class="text-muted-foreground" />
|
<FolderIcon class="text-muted-foreground" />
|
||||||
@@ -78,22 +79,21 @@
|
|||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton class="text-sidebar-foreground/70" onclick={handleMoreClick}>
|
<Sidebar.MenuButton class="text-sidebar-foreground/70" onclick={handleMoreClick}>
|
||||||
<EllipsisIcon class="text-sidebar-foreground/70" />
|
<EllipsisIcon class="text-sidebar-foreground/70" />
|
||||||
<span>More</span>
|
<span>More</span>
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
|
|
||||||
<DropdownMenu.Root bind:open>
|
<DropdownMenu.Root bind:open>
|
||||||
<DropdownMenu.Trigger class="fixed z-50 size-0" style="top: {position.y}px; left: {position.x}px" />
|
<DropdownMenu.Trigger
|
||||||
<DropdownMenu.Content
|
class="fixed z-50 size-0"
|
||||||
class="w-48 rounded-lg"
|
style="top: {position.y}px; left: {position.x}px"
|
||||||
side="right"
|
/>
|
||||||
align="start"
|
<DropdownMenu.Content class="w-48 rounded-lg" side="right" align="start">
|
||||||
>
|
|
||||||
<DropdownMenu.Item>
|
<DropdownMenu.Item>
|
||||||
<a href="/dashboard/csv-upload" class="flex items-center gap-2 w-full">
|
<a href="/dashboard/csv-upload" class="flex w-full items-center gap-2">
|
||||||
<FolderIcon class="size-4 text-muted-foreground" />
|
<FolderIcon class="size-4 text-muted-foreground" />
|
||||||
<span>Carga CSV</span>
|
<span>Carga CSV</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog } from "bits-ui";
|
||||||
|
const AlertDialogPrimitive = AlertDialog;
|
||||||
import Trigger from "./alert-dialog-trigger.svelte";
|
import Trigger from "./alert-dialog-trigger.svelte";
|
||||||
import Title from "./alert-dialog-title.svelte";
|
import Title from "./alert-dialog-title.svelte";
|
||||||
import Action from "./alert-dialog-action.svelte";
|
import Action from "./alert-dialog-action.svelte";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog } from "bits-ui";
|
||||||
|
|
||||||
import Title from "./dialog-title.svelte";
|
import Title from "./dialog-title.svelte";
|
||||||
import Footer from "./dialog-footer.svelte";
|
import Footer from "./dialog-footer.svelte";
|
||||||
@@ -9,8 +9,8 @@ import Description from "./dialog-description.svelte";
|
|||||||
import Trigger from "./dialog-trigger.svelte";
|
import Trigger from "./dialog-trigger.svelte";
|
||||||
import Close from "./dialog-close.svelte";
|
import Close from "./dialog-close.svelte";
|
||||||
|
|
||||||
const Root = DialogPrimitive.Root;
|
const Root = Dialog.Root;
|
||||||
const Portal = DialogPrimitive.Portal;
|
const Portal = Dialog.Portal;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
import { Dialog } from "bits-ui";
|
||||||
|
const SheetPrimitive = Dialog;
|
||||||
import Trigger from "./sheet-trigger.svelte";
|
import Trigger from "./sheet-trigger.svelte";
|
||||||
import Close from "./sheet-close.svelte";
|
import Close from "./sheet-close.svelte";
|
||||||
import Overlay from "./sheet-overlay.svelte";
|
import Overlay from "./sheet-overlay.svelte";
|
||||||
|
|||||||
9
frontend/src/lib/stores/help.svelte.ts
Normal file
9
frontend/src/lib/stores/help.svelte.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Store to control the Help Drawer state globally
|
||||||
|
// Using Svelte 5 runes
|
||||||
|
|
||||||
|
export const helpStore = $state({
|
||||||
|
isOpen: false,
|
||||||
|
open() { this.isOpen = true; },
|
||||||
|
close() { this.isOpen = false; },
|
||||||
|
toggle() { this.isOpen = !this.isOpen; }
|
||||||
|
});
|
||||||
@@ -4,15 +4,15 @@
|
|||||||
import { Toaster } from 'svelte-sonner';
|
import { Toaster } from 'svelte-sonner';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { handleApiError } from '$lib/utils/error-handler';
|
import { handleApiError } from '$lib/utils/error-handler';
|
||||||
import KeyboardManager from '$lib/components/keyboard/KeyboardManager.svelte';
|
import KeyboardManager from '$lib/components/keyboard/KeyboardManager.svelte';
|
||||||
|
import HelpDrawer from '$lib/components/help/HelpDrawer.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
// Detectar errores de CUALQUIER página (layout o page)
|
// Detectar errores de CUALQUIER página (layout o page)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const pageData = $page.data as any;
|
const pageData = $page.data as any;
|
||||||
if (pageData?.error) {
|
if (pageData?.error) {
|
||||||
handleApiError(pageData.error);
|
handleApiError(pageData.error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -24,7 +24,14 @@
|
|||||||
|
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
<KeyboardManager />
|
<KeyboardManager />
|
||||||
|
<HelpDrawer />
|
||||||
<!-- Hidden Global Search Input for Shortcuts -->
|
<!-- Hidden Global Search Input for Shortcuts -->
|
||||||
<input id="global-search-input" type="text" class="sr-only" placeholder="Global Search..." onfocus={() => console.log('Global Search Focused')} />
|
<input
|
||||||
|
id="global-search-input"
|
||||||
|
type="text"
|
||||||
|
class="sr-only"
|
||||||
|
placeholder="Global Search..."
|
||||||
|
onfocus={() => console.log('Global Search Focused')}
|
||||||
|
/>
|
||||||
|
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
266
frontend/src/routes/dashboard/help-center/+page.svelte
Normal file
266
frontend/src/routes/dashboard/help-center/+page.svelte
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
|
import { helpApi, type HelpArticle } from '$lib/api/help';
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
|
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||||
|
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,
|
||||||
|
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);
|
||||||
|
let showEditModal = $state(false);
|
||||||
|
let showDeleteDialog = $state(false);
|
||||||
|
let processing = $state(false);
|
||||||
|
|
||||||
|
let selectedArticle: HelpArticle | null = $state(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let formValues = $state({
|
||||||
|
title: '',
|
||||||
|
slug: '',
|
||||||
|
content: '',
|
||||||
|
last_editor: 'Admin',
|
||||||
|
category: 'General',
|
||||||
|
order: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derived state: Grouped by Category
|
||||||
|
const groupedArticles = $derived.by(() => {
|
||||||
|
const filtered = articles
|
||||||
|
.filter(
|
||||||
|
(a) =>
|
||||||
|
a.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
a.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(a.category || 'General').toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
.sort((a, b) => (a.order || 0) - (b.order || 0)); // Sort by order first
|
||||||
|
|
||||||
|
const groups: Record<string, HelpArticle[]> = {};
|
||||||
|
filtered.forEach((article) => {
|
||||||
|
const cat = article.category || 'General';
|
||||||
|
if (!groups[cat]) groups[cat] = [];
|
||||||
|
groups[cat].push(article);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadArticles();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadArticles() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
articles = await helpApi.listArticles();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('Error al cargar artículos: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
window.location.href = '/dashboard/help-center/editor/new';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(article: HelpArticle) {
|
||||||
|
window.location.href = `/dashboard/help-center/editor/${article.uuid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!selectedArticle) return;
|
||||||
|
processing = true;
|
||||||
|
try {
|
||||||
|
await helpApi.deleteArticle(selectedArticle.uuid);
|
||||||
|
toast.success('Capítulo eliminado');
|
||||||
|
showDeleteDialog = false;
|
||||||
|
await loadArticles();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('Error: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete(article: HelpArticle) {
|
||||||
|
selectedArticle = article;
|
||||||
|
showDeleteDialog = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col space-y-6 rounded-xl bg-muted/10 p-4">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="flex items-center gap-2 text-3xl font-bold tracking-tight text-primary">
|
||||||
|
<Book class="h-8 w-8" />
|
||||||
|
Biblioteca de Conocimiento
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground">Manuales, Guías y Documentación del Sistema.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if HUB_MODE}
|
||||||
|
<Button href="/dashboard/help-center/editor/new" class="shadow-lg">
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
Nuevo Capítulo
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="relative max-w-lg">
|
||||||
|
<Search class="absolute top-3 left-3 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Buscar en la biblioteca..."
|
||||||
|
class="h-10 border-muted-foreground/20 bg-background pl-10"
|
||||||
|
bind:value={searchTerm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content: Shelves -->
|
||||||
|
<div class="flex-1 space-y-8 overflow-auto pr-2">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex h-40 items-center justify-center">
|
||||||
|
<Loader2 class="h-10 w-10 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
{:else if Object.keys(groupedArticles).length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<Book class="mb-4 h-16 w-16 opacity-20" />
|
||||||
|
<p>La biblioteca está vacía.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each Object.entries(groupedArticles) as [category, groupArticles]}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h2
|
||||||
|
class="flex items-center gap-2 border-b pb-2 text-xl font-semibold text-foreground/80"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="rounded bg-primary/10 px-2 py-1 text-sm tracking-wide text-primary uppercase"
|
||||||
|
>{category}</span
|
||||||
|
>
|
||||||
|
</h2>
|
||||||
|
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{#each groupArticles as article}
|
||||||
|
<Card.Root
|
||||||
|
class="group relative flex h-full flex-col overflow-hidden border-muted-foreground/10 bg-background transition-all duration-300 hover:border-primary/50 hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-0 h-full w-1 bg-primary/0 transition-all group-hover:bg-primary"
|
||||||
|
></div>
|
||||||
|
<Card.Header class="pb-2">
|
||||||
|
<Card.Title
|
||||||
|
class="line-clamp-2 flex items-center gap-2 text-lg transition-colors group-hover:text-primary"
|
||||||
|
>
|
||||||
|
{#if article.content_type === 'pdf'}
|
||||||
|
<FileText class="h-5 w-5 text-red-500" />
|
||||||
|
{:else if article.content_type === 'video'}
|
||||||
|
<Video class="h-5 w-5 text-blue-500" />
|
||||||
|
{:else if article.content_type === 'article'}
|
||||||
|
<Book class="h-5 w-5 text-primary" />
|
||||||
|
{:else}
|
||||||
|
<FileCode class="h-5 w-5 text-muted-foreground" />
|
||||||
|
{/if}
|
||||||
|
{article.title}
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description class="text-xs">
|
||||||
|
{new Date(article.updated_at).toLocaleDateString()}
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="flex-grow pt-2">
|
||||||
|
{#if article.content_type === 'article'}
|
||||||
|
<div class="prose prose-sm line-clamp-3 text-sm text-muted-foreground">
|
||||||
|
{@html (article.content || '').replace(/<[^>]*>?/gm, '').substring(0, 150)}...
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
<p>Formato: {article.mime_type || 'Desconocido'}</p>
|
||||||
|
{#if article.file_size}
|
||||||
|
<p>Tamaño: {(article.file_size / 1024 / 1024).toFixed(2)} MB</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
<Card.Footer class="flex items-center justify-between border-t bg-muted/5 pt-4">
|
||||||
|
<Button variant="ghost" size="sm" href={`/dashboard/help-center/${article.uuid}`}>
|
||||||
|
{#if article.content_type === 'video'}
|
||||||
|
Ver
|
||||||
|
{:else if article.content_type === 'article'}
|
||||||
|
Leer
|
||||||
|
{:else}
|
||||||
|
Abrir
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
<div class="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
{#if HUB_MODE}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
href={`/dashboard/help-center/editor/${article.uuid}`}
|
||||||
|
>
|
||||||
|
<Edit class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 text-destructive"
|
||||||
|
onclick={() => openDelete(article)}
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation -->
|
||||||
|
<AlertDialog.Root bind:open={showDeleteDialog}>
|
||||||
|
<AlertDialog.Content>
|
||||||
|
<AlertDialog.Header>
|
||||||
|
<AlertDialog.Title>¿Eliminar capítulo?</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>
|
||||||
|
Se eliminará permanentemente "{selectedArticle?.title}".
|
||||||
|
</AlertDialog.Description>
|
||||||
|
</AlertDialog.Header>
|
||||||
|
<AlertDialog.Footer>
|
||||||
|
<AlertDialog.Cancel onclick={() => (showDeleteDialog = false)}>Cancelar</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action
|
||||||
|
class="text-destructive-foreground bg-destructive hover:bg-destructive/90"
|
||||||
|
onclick={handleDelete}
|
||||||
|
>
|
||||||
|
{#if processing}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
|
||||||
|
Eliminar
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</AlertDialog.Footer>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Root>
|
||||||
276
frontend/src/routes/dashboard/help-center/[uuid]/+page.svelte
Normal file
276
frontend/src/routes/dashboard/help-center/[uuid]/+page.svelte
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
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,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
ExternalLink
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
// Helper to resolve an API path from the DB (e.g., /api/uploads/...) to a full URL pointing to the local backend.
|
||||||
|
function resolveAssetUrl(url: string | undefined): string {
|
||||||
|
try {
|
||||||
|
if (!url) return '';
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||||
|
|
||||||
|
// Fallback to empty string if VITE_API_URL is missing
|
||||||
|
// @ts-ignore
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL || '';
|
||||||
|
const base = apiUrl.replace(/\/$/, '');
|
||||||
|
const cleanUrl = url.replace(/^\/api\//, '/');
|
||||||
|
const finalUrl = `${base}${cleanUrl}`;
|
||||||
|
return finalUrl;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SSR DEBUG] Error in resolveAssetUrl:', e);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let article: HelpArticle | null = null; // Removed $state() as it's not reactive by itself in Svelte 5 standard store usage unless wrapped
|
||||||
|
let loading = true; // Use standard value, reactive via variable assignment in Svelte 5 runes if enabled
|
||||||
|
let error: string | null = null;
|
||||||
|
let toc: { id: string; text: string; level: number }[] = [];
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const uuid = $page.params.uuid;
|
||||||
|
try {
|
||||||
|
article = await helpApi.getArticle(uuid);
|
||||||
|
if (article) {
|
||||||
|
parseTOC(article.content);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseTOC(content: string) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
toc = [];
|
||||||
|
let inCodeBlock = false; // Simple check to avoid parsing headers inside code blocks
|
||||||
|
|
||||||
|
lines.forEach((line) => {
|
||||||
|
if (line.trim().startsWith('```')) {
|
||||||
|
inCodeBlock = !inCodeBlock;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inCodeBlock) return;
|
||||||
|
|
||||||
|
const match = line.match(/^(#{1,3})\s+(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
const level = match[1].length;
|
||||||
|
const text = match[2];
|
||||||
|
const id = text.toLowerCase().replace(/[^\w]+/g, '-');
|
||||||
|
toc = [...toc, { id, text, level }];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
function renderMarkdown(content: string) {
|
||||||
|
if (!content) return '';
|
||||||
|
try {
|
||||||
|
const rawHtml = marked.parse(content) as string;
|
||||||
|
// Add IDs to headers for TOC navigation
|
||||||
|
let htmlWithIds = rawHtml.replace(/<h([1-3])>(.*?)<\/h\1>/g, (match, level, text) => {
|
||||||
|
const id = text.toLowerCase().replace(/[^\w]+/g, '-');
|
||||||
|
return `<h${level} id="${id}">${text}</h${level}>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rewrite image src and link hrefs to point to the local backend
|
||||||
|
htmlWithIds = htmlWithIds.replace(/src="([^"]+)"/g, (match, src) => {
|
||||||
|
return `src="${resolveAssetUrl(src)}"`;
|
||||||
|
});
|
||||||
|
htmlWithIds = htmlWithIds.replace(/href="(\/api\/uploads\/[^"]+)"/g, (match, href) => {
|
||||||
|
return `href="${resolveAssetUrl(href)}"`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (browser && typeof window !== 'undefined') {
|
||||||
|
// DOMPurify only works in the browser
|
||||||
|
return DOMPurify.sanitize(htmlWithIds);
|
||||||
|
}
|
||||||
|
return htmlWithIds; // SSR returns raw HTML (or could use isomorphic-dompurify)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Markdown parsing error:', e);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToHeader(id: string) {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col bg-background">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div
|
||||||
|
class="sticky top-0 z-10 flex items-center gap-4 border-b bg-card/50 px-6 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="sm" href="/dashboard/help-center" class="gap-2">
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
Volver a la Biblioteca
|
||||||
|
</Button>
|
||||||
|
<div class="mx-2 h-4 w-px bg-border"></div>
|
||||||
|
{#if article}
|
||||||
|
<span class="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
|
<BookOpen class="h-4 w-4" />
|
||||||
|
{article.category || 'General'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative flex-1 overflow-hidden">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex h-full items-center justify-center">
|
||||||
|
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex h-full flex-col items-center justify-center gap-2 text-destructive">
|
||||||
|
<p class="text-lg font-medium">Error al cargar el capítulo</p>
|
||||||
|
<p class="text-sm text-muted-foreground">{error}</p>
|
||||||
|
</div>
|
||||||
|
{:else if article}
|
||||||
|
<div class="flex h-full">
|
||||||
|
<!-- Article Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto scroll-smooth px-8 py-10 md:px-12 lg:px-16">
|
||||||
|
<div class="mx-auto max-w-4xl pb-20">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8 border-b pb-6">
|
||||||
|
<h1 class="mb-4 text-4xl font-bold tracking-tight text-foreground">
|
||||||
|
{article.title}
|
||||||
|
</h1>
|
||||||
|
<div class="flex items-center gap-6 text-sm text-muted-foreground">
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<User class="h-4 w-4" />
|
||||||
|
{article.last_editor}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<Calendar class="h-4 w-4" />
|
||||||
|
{new Date(article.updated_at).toLocaleDateString(undefined, {
|
||||||
|
dateStyle: 'long'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
{#if article.content_type === 'article'}
|
||||||
|
<article
|
||||||
|
class="prose max-w-none prose-slate dark:prose-invert prose-headings:scroll-mt-20 prose-img:rounded-lg prose-img:shadow-md"
|
||||||
|
>
|
||||||
|
{@html renderMarkdown(article.content)}
|
||||||
|
</article>
|
||||||
|
{:else if article.content_type === 'pdf'}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between rounded-lg border bg-muted/30 p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<FileText class="h-8 w-8 text-red-500" />
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Documento PDF</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{article.file_size ? (article.file_size / 1024 / 1024).toFixed(2) : '??'} MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
href={resolveAssetUrl(article.file_url)}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<ExternalLink class="mr-2 h-4 w-4" />
|
||||||
|
Abrir en pestaña
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
href={resolveAssetUrl(article.file_url)}
|
||||||
|
download={article.title}
|
||||||
|
>
|
||||||
|
<Download class="mr-2 h-4 w-4" />
|
||||||
|
Descargar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- PDF Embed -->
|
||||||
|
<iframe
|
||||||
|
src={resolveAssetUrl(article.file_url)}
|
||||||
|
title={article.title}
|
||||||
|
class="h-[800px] w-full rounded-xl border shadow-inner"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
{:else if article.content_type === 'video'}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<video controls class="w-full rounded-xl border shadow-lg">
|
||||||
|
<source src={resolveAssetUrl(article.file_url)} type={article.mime_type} />
|
||||||
|
Tu navegador no soporta videos.
|
||||||
|
</video>
|
||||||
|
<div class="rounded-lg border bg-muted/30 p-4">
|
||||||
|
<p class="text-sm font-medium">Información del archivo</p>
|
||||||
|
<p class="text-xs text-muted-foreground">{article.mime_type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center gap-6 rounded-2xl border border-dashed py-20"
|
||||||
|
>
|
||||||
|
<div class="rounded-full bg-primary/10 p-6">
|
||||||
|
<Download class="h-12 w-12 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<h3 class="text-xl font-semibold">Archivo para descargar</h3>
|
||||||
|
<p class="text-muted-foreground truncate max-w-md">
|
||||||
|
Este archivo no tiene vista previa directa.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
href={resolveAssetUrl(article.file_url)}
|
||||||
|
download={article.title}
|
||||||
|
class="px-8 shadow-lg"
|
||||||
|
>
|
||||||
|
<Download class="mr-2 h-4 w-4" />
|
||||||
|
Descargar ahora
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOC Sidebar (Desktop) -->
|
||||||
|
{#if toc.length > 0}
|
||||||
|
<div class="hidden w-72 overflow-y-auto border-l bg-muted/5 p-6 xl:block">
|
||||||
|
<h4 class="mb-4 text-sm font-semibold tracking-wider text-muted-foreground uppercase">
|
||||||
|
En este capitulo
|
||||||
|
</h4>
|
||||||
|
<nav class="space-y-1">
|
||||||
|
{#each toc as item}
|
||||||
|
<button
|
||||||
|
class="block w-full py-1 text-left text-sm text-muted-foreground transition-colors hover:text-foreground
|
||||||
|
{item.level === 1 ? 'font-medium' : 'pl-' + item.level * 2}
|
||||||
|
"
|
||||||
|
onclick={() => scrollToHeader(item.id)}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
|
import { helpApi, type HelpArticle } from '$lib/api/help';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Save,
|
||||||
|
ArrowLeft,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Link as LinkIcon,
|
||||||
|
List,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
FileText,
|
||||||
|
Video as VideoIcon,
|
||||||
|
FileCode,
|
||||||
|
Upload
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
// Helper to resolve an API path from the DB (e.g., /api/uploads/...) to a full URL pointing to the local backend.
|
||||||
|
function resolveAssetUrl(url: string | undefined): string {
|
||||||
|
if (!url) return '';
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||||
|
|
||||||
|
// Fallback to empty string if VITE_API_URL is missing
|
||||||
|
// @ts-ignore
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL || '';
|
||||||
|
const base = apiUrl.replace(/\/$/, '');
|
||||||
|
const cleanUrl = url.replace(/^\/api\//, '/');
|
||||||
|
return `${base}${cleanUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let article = $state<HelpArticle | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let processing = $state(false);
|
||||||
|
|
||||||
|
// Editor State
|
||||||
|
let title = $state('');
|
||||||
|
let slug = $state('');
|
||||||
|
let content = $state('');
|
||||||
|
let category = $state('General');
|
||||||
|
let order = $state(0);
|
||||||
|
let last_editor = $state('Admin');
|
||||||
|
let content_type = $state('article');
|
||||||
|
let file_url = $state('');
|
||||||
|
let file_size = $state<number | undefined>(undefined);
|
||||||
|
let mime_type = $state('');
|
||||||
|
|
||||||
|
// Preview State
|
||||||
|
let showPreview = $state(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;
|
||||||
|
// Defaults for new article
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
article = await helpApi.getArticle(uuid);
|
||||||
|
if (article) {
|
||||||
|
title = article.title;
|
||||||
|
slug = article.slug;
|
||||||
|
content = article.content || '';
|
||||||
|
category = article.category || 'General';
|
||||||
|
order = article.order || 0;
|
||||||
|
last_editor = article.last_editor || 'Admin';
|
||||||
|
content_type = article.content_type || 'article';
|
||||||
|
file_url = article.file_url || '';
|
||||||
|
file_size = article.file_size ?? undefined;
|
||||||
|
mime_type = article.mime_type || '';
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('Error al cargar artículo: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
processing = true;
|
||||||
|
try {
|
||||||
|
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) {
|
||||||
|
data.slug = data.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ /g, '-')
|
||||||
|
.replace(/[^\w-]+/g, '');
|
||||||
|
slug = data.slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid = $page.params.uuid as string;
|
||||||
|
if (uuid === 'new') {
|
||||||
|
const newArticle = await helpApi.createArticle(data);
|
||||||
|
toast.success('Capítulo creado');
|
||||||
|
// Redirect to edit mode or list? For now, stay here but update URL would be ideal.
|
||||||
|
// simpler to just go back to list or reload.
|
||||||
|
// Let's redirect to edit mode of this new UUID to avoid duplicates on re-save
|
||||||
|
window.location.href = `/dashboard/help-center/editor/${newArticle.uuid}`;
|
||||||
|
} else {
|
||||||
|
await helpApi.updateArticle(uuid, data);
|
||||||
|
toast.success('Cambios guardados');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('Error al guardar: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertText(prefix: string, suffix: string = '') {
|
||||||
|
const textarea = document.getElementById('markdown-editor') as HTMLTextAreaElement;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const text = textarea.value;
|
||||||
|
const selection = text.substring(start, end);
|
||||||
|
|
||||||
|
const before = text.substring(0, start);
|
||||||
|
const after = text.substring(end);
|
||||||
|
|
||||||
|
content = `${before}${prefix}${selection}${suffix}${after}`;
|
||||||
|
|
||||||
|
// Restore focus and selection
|
||||||
|
tick().then(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImageUpload(file: File) {
|
||||||
|
const tid = toast.loading('Subiendo imagen...');
|
||||||
|
processing = true;
|
||||||
|
try {
|
||||||
|
const result = await helpApi.uploadImage(file);
|
||||||
|
const imageMarkdown = `\n\n`;
|
||||||
|
insertText(imageMarkdown);
|
||||||
|
toast.success('Imagen insertada', { id: tid });
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('Error al subir: ' + e.message, { id: tid });
|
||||||
|
} finally {
|
||||||
|
processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssetUpload(file: File) {
|
||||||
|
const tid = toast.loading(`Subiendo ${file.name}...`);
|
||||||
|
processing = true;
|
||||||
|
try {
|
||||||
|
const result = await helpApi.uploadAsset(file);
|
||||||
|
file_url = result.url;
|
||||||
|
file_size = result.size;
|
||||||
|
mime_type = result.mime_type;
|
||||||
|
|
||||||
|
// Auto-set title if empty
|
||||||
|
if (!title) {
|
||||||
|
title = file.name.split('.').shift() || 'Sin Título';
|
||||||
|
}
|
||||||
|
if (!slug) {
|
||||||
|
slug = title.toLowerCase().replace(/[^\w]+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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', { id: tid });
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('Error al subir archivo: ' + e.message, { id: tid });
|
||||||
|
} finally {
|
||||||
|
processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
const file = files[0];
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
handleImageUpload(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(text: string) {
|
||||||
|
if (!text) return '';
|
||||||
|
try {
|
||||||
|
const html = marked.parse(text) as string;
|
||||||
|
|
||||||
|
// Rewrite image src and link hrefs to point to the local backend
|
||||||
|
let resolvedHtml = html.replace(/src="([^"]+)"/g, (match, src) => {
|
||||||
|
return `src="${resolveAssetUrl(src)}"`;
|
||||||
|
});
|
||||||
|
resolvedHtml = resolvedHtml.replace(/href="(\/api\/uploads\/[^"]+)"/g, (match, href) => {
|
||||||
|
return `href="${resolveAssetUrl(href)}"`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (browser && typeof window !== 'undefined') {
|
||||||
|
return DOMPurify.sanitize(resolvedHtml);
|
||||||
|
}
|
||||||
|
return resolvedHtml;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Markdown parsing error in editor:", e);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-screen flex-col overflow-hidden bg-background">
|
||||||
|
<!-- Top Bar -->
|
||||||
|
<header class="z-10 flex items-center justify-between border-b bg-card px-6 py-3">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" href="/dashboard/help-center">
|
||||||
|
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
<h1 class="max-w-md truncate text-lg font-semibold">
|
||||||
|
{title || 'Sin Título'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onclick={() => (showPreview = !showPreview)}>
|
||||||
|
{showPreview ? 'Ocultar Vista Previa' : 'Ver Vista Previa'}
|
||||||
|
</Button>
|
||||||
|
<Button onclick={handleSave} disabled={processing || !title}>
|
||||||
|
{#if processing}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
|
||||||
|
<Save class="mr-2 h-4 w-4" />
|
||||||
|
Guardar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Editor Area -->
|
||||||
|
<div class="flex flex-1 overflow-hidden">
|
||||||
|
<!-- Metadata Sidebar (Left) - Collapsible or Fixed width -->
|
||||||
|
<aside class="hidden w-64 overflow-y-auto border-r bg-muted/10 p-4 lg:block">
|
||||||
|
<h3 class="mb-4 text-sm font-semibold tracking-wider text-muted-foreground uppercase">
|
||||||
|
Configuración
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="title">Título</Label>
|
||||||
|
<Input id="title" bind:value={title} placeholder="Ej: Introducción" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 border-t pt-4">
|
||||||
|
<Label for="type">Tipo de Contenido</Label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
bind:value={content_type}
|
||||||
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="article">Artículo (Markdown)</option>
|
||||||
|
<option value="pdf">PDF</option>
|
||||||
|
<option value="video">Video</option>
|
||||||
|
<option value="document">Otro Documento</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 border-t pt-4">
|
||||||
|
<Label for="category">Categoría</Label>
|
||||||
|
<Input id="category" bind:value={category} placeholder="Ej: General" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 border-t pt-4">
|
||||||
|
<Label for="order">Orden</Label>
|
||||||
|
<Input id="order" type="number" bind:value={order} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Split View -->
|
||||||
|
<main class="flex flex-1 overflow-hidden">
|
||||||
|
{#if content_type === 'article'}
|
||||||
|
<!-- Editor -->
|
||||||
|
<div class="group relative flex flex-1 flex-col border-r">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="flex items-center gap-1 border-b bg-background p-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
onclick={() => insertText('**', '**')}
|
||||||
|
title="Negrita"
|
||||||
|
>
|
||||||
|
<Bold class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
onclick={() => insertText('*', '*')}
|
||||||
|
title="Cursiva"
|
||||||
|
>
|
||||||
|
<Italic class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div class="mx-1 h-4 w-px bg-border"></div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
onclick={() => insertText('# ')}
|
||||||
|
title="Título 1"
|
||||||
|
>
|
||||||
|
<Heading1 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
onclick={() => insertText('## ')}
|
||||||
|
title="Título 2"
|
||||||
|
>
|
||||||
|
<Heading2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div class="mx-1 h-4 w-px bg-border"></div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
onclick={() => insertText('- ')}
|
||||||
|
title="Lista"
|
||||||
|
>
|
||||||
|
<List class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
onclick={() => insertText('[texto](url)')}
|
||||||
|
title="Enlace"
|
||||||
|
>
|
||||||
|
<LinkIcon class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<label
|
||||||
|
class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
title="Subir Imagen"
|
||||||
|
>
|
||||||
|
<ImageIcon class="h-4 w-4" />
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onchange={(e) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
if (target.files?.length) handleImageUpload(target.files[0]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id="markdown-editor"
|
||||||
|
bind:value={content}
|
||||||
|
class="w-full flex-1 resize-none bg-background p-8 font-mono text-sm leading-relaxed outline-none"
|
||||||
|
placeholder="# Empieza a escribir aquí..."
|
||||||
|
ondrop={(e: DragEvent) => handleDrop(e)}
|
||||||
|
ondragover={(e: DragEvent) => e.preventDefault()}
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<!-- Dropping Hint -->
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-0 m-4 hidden items-center justify-center rounded-lg border-2 border-dashed border-primary bg-primary/10 opacity-0 transition-opacity group-hover:flex"
|
||||||
|
>
|
||||||
|
<p class="font-medium text-primary">Arrastra imágenes aquí</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
{#if showPreview}
|
||||||
|
<div class="w-1/2 flex-1 overflow-y-auto border-l bg-muted/5 p-8">
|
||||||
|
<div class="prose max-w-none prose-slate dark:prose-invert">
|
||||||
|
{@html renderMarkdown(content)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<!-- Asset Upload View -->
|
||||||
|
<div class="flex flex-1 flex-col items-center justify-center bg-muted/5 p-12">
|
||||||
|
<div class="w-full max-w-2xl space-y-8 text-center">
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
{#if content_type === 'pdf'}
|
||||||
|
<div class="rounded-full bg-red-100 p-6 dark:bg-red-900/20">
|
||||||
|
<FileText class="h-16 w-16 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold">Configuración de PDF</h2>
|
||||||
|
{:else if content_type === 'video'}
|
||||||
|
<div class="rounded-full bg-blue-100 p-6 dark:bg-blue-900/20">
|
||||||
|
<VideoIcon class="h-16 w-16 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold">Configuración de Video</h2>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-full bg-primary/10 p-6">
|
||||||
|
<FileCode class="h-16 w-16 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold">Configuración de Documento</h2>
|
||||||
|
{/if}
|
||||||
|
<p class="text-muted-foreground">Sube el archivo que deseas asociar a este título.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if file_url}
|
||||||
|
<div class="relative flex flex-col gap-4 rounded-xl border bg-card p-6 shadow-sm">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="rounded bg-muted p-2">
|
||||||
|
<FileCode class="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 text-left">
|
||||||
|
<p class="font-medium">Archivo Cargado</p>
|
||||||
|
<p class="truncate text-xs text-muted-foreground">{file_url}</p>
|
||||||
|
<p class="font-mono text-xs">
|
||||||
|
{mime_type} • {file_size ? (file_size / 1024 / 1024).toFixed(2) : '??'} MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="outline" class="flex-1" href={resolveAssetUrl(file_url)} target="_blank">
|
||||||
|
Ver Archivo
|
||||||
|
</Button>
|
||||||
|
<label
|
||||||
|
class="flex flex-1 cursor-pointer items-center justify-center rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Cambiar Archivo
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
onchange={(e) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
if (target.files?.length) handleAssetUpload(target.files[0]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<label
|
||||||
|
class="flex h-64 w-full cursor-pointer flex-col items-center justify-center gap-4 rounded-2xl border-2 border-dashed border-muted-foreground/20 bg-muted/20 transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<div class="rounded-full bg-background p-4 shadow-sm">
|
||||||
|
<Upload class="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span class="font-medium">Selecciona un archivo</span>
|
||||||
|
<span class="text-xs text-muted-foreground">O arrastra y suelta aquí</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
onchange={(e) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
if (target.files?.length) handleAssetUpload(target.files[0]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -10,7 +10,13 @@ export default defineConfig({
|
|||||||
allowedHosts: [
|
allowedHosts: [
|
||||||
'anexo76-dev.aduanasoft.com',
|
'anexo76-dev.aduanasoft.com',
|
||||||
// 'otro-host.com' si necesitas más
|
// 'otro-host.com' si necesitas más
|
||||||
],
|
],
|
||||||
|
proxy: {
|
||||||
|
'/api/uploads': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
|||||||
@@ -40,5 +40,12 @@ echo "=========================================="
|
|||||||
echo "Iniciando aplicación SvelteKit..."
|
echo "Iniciando aplicación SvelteKit..."
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|
||||||
|
# Instalar dependencias nuevas si package.json ha cambiado
|
||||||
|
if [ "$NODE_ENV" = "development" ]; then
|
||||||
|
echo "Instalando dependencias (development mode)..."
|
||||||
|
# CI=true evita el error ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY
|
||||||
|
CI=true pnpm install
|
||||||
|
fi
|
||||||
|
|
||||||
# Ejecutar el comando que se pasó al contenedor
|
# Ejecutar el comando que se pasó al contenedor
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
#
|
#
|
||||||
# Requisitos:
|
# Requisitos:
|
||||||
# - Keycloak corriendo en http://localhost:8080
|
# - Keycloak corriendo en http://localhost:8080
|
||||||
# - PostgreSQL corriendo en localhost:5432
|
# - PostgreSQL corriendo en localhost:5432 o 5939
|
||||||
# - Base de datos anexo76_core creada
|
# - Base de datos anexo76_core creada
|
||||||
# - jq instalado (para procesamiento JSON)
|
# - jq instalado (para procesamiento JSON)
|
||||||
#
|
#
|
||||||
@@ -86,6 +86,17 @@ exec_pg_sql() {
|
|||||||
-t -c "${sql}" 2>&1
|
-t -c "${sql}" 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Ejecutar SQL en el PostgreSQL del Cliente (Simulación)
|
||||||
|
exec_pg_sql_client() {
|
||||||
|
local sql="$1"
|
||||||
|
# Solo ejecutar si el contenedor existe y está corriendo
|
||||||
|
if docker ps --format '{{.Names}}' | grep -q "^anexo76-postgres-client$"; then
|
||||||
|
docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-client \
|
||||||
|
psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "anexo76_client" \
|
||||||
|
-t -c "${sql}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Crear mapper de tenant_id para un cliente
|
# Crear mapper de tenant_id para un cliente
|
||||||
create_tenant_mapper() {
|
create_tenant_mapper() {
|
||||||
local client_id="$1"
|
local client_id="$1"
|
||||||
@@ -303,7 +314,7 @@ KEYCLOAK_URL="${KEYCLOAK_URL:-http://localhost:8080/kcauth}"
|
|||||||
KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN:-admin}"
|
KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN:-admin}"
|
||||||
KEYCLOAK_ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD:-admin}"
|
KEYCLOAK_ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD:-admin}"
|
||||||
KEYCLOAK_REALM="${KEYCLOAK_REALM:-master}"
|
KEYCLOAK_REALM="${KEYCLOAK_REALM:-master}"
|
||||||
KEYCLOACK_ADMIN_URL="${KEYCLOACK_ADMIN_URL:-http://localhost:9000/kcauth}"
|
KEYCLOAK_ADMIN_URL="${KEYCLOAK_ADMIN_URL:-http://localhost:9000/kcauth}"
|
||||||
|
|
||||||
POSTGRES_HOST="${POSTGRES_HOST:-localhost}"
|
POSTGRES_HOST="${POSTGRES_HOST:-localhost}"
|
||||||
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
||||||
@@ -334,7 +345,7 @@ MAX_RETRIES=30
|
|||||||
RETRY_COUNT=0
|
RETRY_COUNT=0
|
||||||
|
|
||||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||||
if curl -s -f "${KEYCLOACK_ADMIN_URL}/health/ready" > /dev/null 2>&1; then
|
if curl -s -f "${KEYCLOAK_ADMIN_URL}/health/ready" > /dev/null 2>&1; then
|
||||||
echo -e "${GREEN}✓ Keycloak está listo${NC}"
|
echo -e "${GREEN}✓ Keycloak está listo${NC}"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -523,8 +534,47 @@ FRONTEND_CLIENT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK
|
|||||||
-H "Content-Type: application/json")
|
-H "Content-Type: application/json")
|
||||||
|
|
||||||
if echo "$FRONTEND_CLIENT_EXISTS" | jq -e '.[] | select(.clientId == "anexo76-frontend")' >/dev/null 2>&1; then
|
if echo "$FRONTEND_CLIENT_EXISTS" | jq -e '.[] | select(.clientId == "anexo76-frontend")' >/dev/null 2>&1; then
|
||||||
echo -e "${YELLOW}⚠ Cliente Frontend ya existe${NC}"
|
echo -e "${YELLOW}⚠ Cliente Frontend ya existe, actualizando configuración...${NC}"
|
||||||
FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | jq -r '.[0].id')
|
FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | jq -r '.[0].id')
|
||||||
|
|
||||||
|
# Actualizar configuración del cliente existente para incluir puertos nuevos
|
||||||
|
UPDATE_FRONTEND=$(curl -s -w "\n%{http_code}" -X PUT "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${FRONTEND_CLIENT_ID}" \
|
||||||
|
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"clientId": "anexo76-frontend",
|
||||||
|
"name": "Anexo76 Frontend",
|
||||||
|
"description": "Aplicación web frontend para el sistema Anexo76",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": true,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"rootUrl": "http://localhost:5173",
|
||||||
|
"baseUrl": "http://localhost:5173",
|
||||||
|
"redirectUris": [
|
||||||
|
"http://localhost:5173/*",
|
||||||
|
"http://localhost:5174/*",
|
||||||
|
"http://localhost:3000/*"
|
||||||
|
],
|
||||||
|
"webOrigins": [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:5174",
|
||||||
|
"http://localhost:3000"
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"pkce.code.challenge.method": "S256"
|
||||||
|
}
|
||||||
|
}')
|
||||||
|
|
||||||
|
HTTP_CODE=$(echo "$UPDATE_FRONTEND" | tail -n1)
|
||||||
|
if [ "$HTTP_CODE" = "204" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo -e "${GREEN}✓ Cliente Frontend actualizado (Puertos actualizados)${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Error al actualizar cliente Frontend (HTTP ${HTTP_CODE})${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
else
|
else
|
||||||
CREATE_FRONTEND=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients" \
|
CREATE_FRONTEND=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients" \
|
||||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||||
@@ -543,10 +593,12 @@ else
|
|||||||
"baseUrl": "http://localhost:5173",
|
"baseUrl": "http://localhost:5173",
|
||||||
"redirectUris": [
|
"redirectUris": [
|
||||||
"http://localhost:5173/*",
|
"http://localhost:5173/*",
|
||||||
|
"http://localhost:5174/*",
|
||||||
"http://localhost:3000/*"
|
"http://localhost:3000/*"
|
||||||
],
|
],
|
||||||
"webOrigins": [
|
"webOrigins": [
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
|
"http://localhost:5174",
|
||||||
"http://localhost:3000"
|
"http://localhost:3000"
|
||||||
],
|
],
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -702,8 +754,9 @@ if [ $PG_RETRY_COUNT -eq $MAX_PG_RETRIES ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Insertar o actualizar tenant
|
# Insertar o actualizar tenant
|
||||||
echo "Insertando tenant en PostgreSQL..."
|
echo "Insertando tenant en PostgreSQL (Hub y Cliente)..."
|
||||||
exec_pg_sql "INSERT INTO core.tenants (name, slug, type, keycloak_realm, contact_email, is_active, created_at, updated_at) VALUES ('${TENANT_NAME}', '${TENANT_SLUG}', 'SHARED'::tenanttype, '${KEYCLOAK_REALM}', '${DEMO_EMAIL}', true, now(), now()) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, contact_email = EXCLUDED.contact_email, updated_at = CURRENT_TIMESTAMP;" >/dev/null
|
exec_pg_sql "INSERT INTO core.tenants (name, slug, type, keycloak_realm, contact_email, is_active, created_at, updated_at) VALUES ('${TENANT_NAME}', '${TENANT_SLUG}', 'SHARED'::tenanttype, '${KEYCLOAK_REALM}', '${DEMO_EMAIL}', true, now(), now()) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, contact_email = EXCLUDED.contact_email, updated_at = CURRENT_TIMESTAMP;" >/dev/null
|
||||||
|
exec_pg_sql_client "INSERT INTO core.tenants (name, slug, type, keycloak_realm, contact_email, is_active, created_at, updated_at) VALUES ('${TENANT_NAME}', '${TENANT_SLUG}', 'SHARED'::tenanttype, '${KEYCLOAK_REALM}', '${DEMO_EMAIL}', true, now(), now()) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, contact_email = EXCLUDED.contact_email, updated_at = CURRENT_TIMESTAMP;" >/dev/null
|
||||||
|
|
||||||
# Obtener el ID del tenant con mejor manejo de errores
|
# Obtener el ID del tenant con mejor manejo de errores
|
||||||
echo "Obteniendo ID del tenant..."
|
echo "Obteniendo ID del tenant..."
|
||||||
@@ -738,7 +791,8 @@ COMPANY_EXISTS=$(echo "$COMPANY_EXISTS" | xargs)
|
|||||||
|
|
||||||
if [ "$COMPANY_EXISTS" = "0" ]; then
|
if [ "$COMPANY_EXISTS" = "0" ]; then
|
||||||
exec_pg_sql "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" >/dev/null
|
exec_pg_sql "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" >/dev/null
|
||||||
echo -e "${GREEN}✓ Company creada${NC}"
|
exec_pg_sql_client "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" >/dev/null
|
||||||
|
echo -e "${GREEN}✓ Company creada (Hub y Cliente)${NC}"
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW}⚠ Company ya existe para este tenant${NC}"
|
echo -e "${YELLOW}⚠ Company ya existe para este tenant${NC}"
|
||||||
fi
|
fi
|
||||||
@@ -768,8 +822,9 @@ echo -e "${GREEN}✓ Atributo tenant_id asignado al usuario${NC}"
|
|||||||
echo -e "\n${YELLOW}Creando relación usuario-tenant en la base de datos...${NC}"
|
echo -e "\n${YELLOW}Creando relación usuario-tenant en la base de datos...${NC}"
|
||||||
|
|
||||||
exec_pg_sql "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null
|
exec_pg_sql "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null
|
||||||
|
exec_pg_sql_client "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null
|
||||||
|
|
||||||
echo -e "${GREEN}✓ Relación usuario-tenant creada en la base de datos${NC}"
|
echo -e "${GREEN}✓ Relación usuario-tenant creada en la base de datos (Hub y Cliente)${NC}"
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
# 9. Crear licencia Enterprise para el tenant
|
# 9. Crear licencia Enterprise para el tenant
|
||||||
@@ -792,7 +847,8 @@ if [ "$LICENSE_EXISTS" = "0" ]; then
|
|||||||
set -e # Reactivar exit on error
|
set -e # Reactivar exit on error
|
||||||
|
|
||||||
if [ $LICENSE_CREATE_STATUS -eq 0 ]; then
|
if [ $LICENSE_CREATE_STATUS -eq 0 ]; then
|
||||||
echo -e "${GREEN}✓ Licencia Enterprise creada exitosamente${NC}"
|
exec_pg_sql_client "INSERT INTO core.licenses (tenant_id, plan, status, max_users, max_storage_gb, max_monthly_operations, feature_api_access, feature_advanced_reports, feature_integrations, feature_dedicated_support, starts_at, expires_at, created_at, updated_at) VALUES (${TENANT_ID}, 'ENTERPRISE', 'ACTIVE', 999999, 999999, 999999, true, true, true, true, '${LICENSE_START_DATE}'::timestamp, '${LICENSE_EXPIRE_DATE}'::timestamp, now(), now());" >/dev/null 2>&1 || true
|
||||||
|
echo -e "${GREEN}✓ Licencia Enterprise creada exitosamente (Hub y Cliente)${NC}"
|
||||||
echo -e "${GREEN} Plan: Enterprise${NC}"
|
echo -e "${GREEN} Plan: Enterprise${NC}"
|
||||||
echo -e "${GREEN} Usuarios: Ilimitados${NC}"
|
echo -e "${GREEN} Usuarios: Ilimitados${NC}"
|
||||||
echo -e "${GREEN} Almacenamiento: Ilimitado${NC}"
|
echo -e "${GREEN} Almacenamiento: Ilimitado${NC}"
|
||||||
@@ -812,11 +868,12 @@ else
|
|||||||
# Actualizar licencia existente a Enterprise
|
# Actualizar licencia existente a Enterprise
|
||||||
set +e # Desactivar exit on error temporalmente
|
set +e # Desactivar exit on error temporalmente
|
||||||
exec_pg_sql "UPDATE core.licenses SET plan = 'ENTERPRISE', status = 'ACTIVE', max_users = 999999, max_storage_gb = 999999, max_monthly_operations = 999999, feature_api_access = true, feature_advanced_reports = true, feature_integrations = true, feature_dedicated_support = true, starts_at = '${LICENSE_START_DATE}'::timestamp, expires_at = '${LICENSE_EXPIRE_DATE}'::timestamp, updated_at = now() WHERE tenant_id = ${TENANT_ID};" >/dev/null 2>&1
|
exec_pg_sql "UPDATE core.licenses SET plan = 'ENTERPRISE', status = 'ACTIVE', max_users = 999999, max_storage_gb = 999999, max_monthly_operations = 999999, feature_api_access = true, feature_advanced_reports = true, feature_integrations = true, feature_dedicated_support = true, starts_at = '${LICENSE_START_DATE}'::timestamp, expires_at = '${LICENSE_EXPIRE_DATE}'::timestamp, updated_at = now() WHERE tenant_id = ${TENANT_ID};" >/dev/null 2>&1
|
||||||
|
exec_pg_sql_client "UPDATE core.licenses SET plan = 'ENTERPRISE', status = 'ACTIVE', max_users = 999999, max_storage_gb = 999999, max_monthly_operations = 999999, feature_api_access = true, feature_advanced_reports = true, feature_integrations = true, feature_dedicated_support = true, starts_at = '${LICENSE_START_DATE}'::timestamp, expires_at = '${LICENSE_EXPIRE_DATE}'::timestamp, updated_at = now() WHERE tenant_id = ${TENANT_ID};" >/dev/null 2>&1 || true
|
||||||
LICENSE_UPDATE_STATUS=$?
|
LICENSE_UPDATE_STATUS=$?
|
||||||
set -e # Reactivar exit on error
|
set -e # Reactivar exit on error
|
||||||
|
|
||||||
if [ $LICENSE_UPDATE_STATUS -eq 0 ]; then
|
if [ $LICENSE_UPDATE_STATUS -eq 0 ]; then
|
||||||
echo -e "${GREEN}✓ Licencia actualizada a Enterprise${NC}"
|
echo -e "${GREEN}✓ Licencia actualizada a Enterprise (Hub y Cliente)${NC}"
|
||||||
else
|
else
|
||||||
echo -e "${RED}✗ Error al actualizar la licencia${NC}"
|
echo -e "${RED}✗ Error al actualizar la licencia${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
52
start.sh
52
start.sh
@@ -60,14 +60,15 @@ fi
|
|||||||
echo -e "${GREEN}✓ Docker está instalado y corriendo${NC}"
|
echo -e "${GREEN}✓ Docker está instalado y corriendo${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 2. Crear archivo .env si no existe
|
# 2. Configurar variables de entorno
|
||||||
echo -e "${BLUE}[2/7] Configurando variables de entorno...${NC}"
|
echo -e "${BLUE}[2/7] Configurando variables de entorno...${NC}"
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
if [ ! -f .env ]; then
|
||||||
if [ -f .env.example ]; then
|
if [ -f .env.example ]; then
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
echo -e "${GREEN}✓ Archivo .env creado${NC}"
|
echo -e "${GREEN}✓ Archivo .env creado desde .env.example${NC}"
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW}⚠ .env.example no existe, creando .env con valores por defecto${NC}"
|
echo -e "${YELLOW}⚠ .env.example no existe, creando .env básico${NC}"
|
||||||
cat > .env <<EOF
|
cat > .env <<EOF
|
||||||
POSTGRES_APP_PASSWORD=postgres
|
POSTGRES_APP_PASSWORD=postgres
|
||||||
POSTGRES_KEYCLOAK_PASSWORD=postgres
|
POSTGRES_KEYCLOAK_PASSWORD=postgres
|
||||||
@@ -83,11 +84,52 @@ NODE_ENV=development
|
|||||||
VITE_API_URL=http://localhost:8000/api
|
VITE_API_URL=http://localhost:8000/api
|
||||||
VITE_KEYCLOAK_URL=http://localhost:8080/kcauth
|
VITE_KEYCLOAK_URL=http://localhost:8080/kcauth
|
||||||
EOF
|
EOF
|
||||||
echo -e "${GREEN}✓ Archivo .env creado con valores por defecto${NC}"
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Preguntar por el rol si no está definido o si el usuario quiere reconfigurar
|
||||||
|
if ! grep -q "VITE_HUB_MODE=" .env || [ "${RECONFIGURE:-false}" == "true" ]; then
|
||||||
|
echo -e "${YELLOW}Configuración de Rol (Hub vs Cliente)${NC}"
|
||||||
|
echo "1) Hub (Servidor Central)"
|
||||||
|
echo "2) Cliente (Sucursal/Spoke)"
|
||||||
|
read -p "Seleccione una opción [1-2, Enter para omitir]: " SERVER_ROLE
|
||||||
|
|
||||||
|
if [ "$SERVER_ROLE" == "1" ] || [ "$SERVER_ROLE" == "2" ]; then
|
||||||
|
GENERATED_TOKEN=$(openssl rand -hex 16 2>/dev/null || echo "dev-sync-token-$(date +%s)")
|
||||||
|
read -p "Ingrese el Token de Sincronización Secreto [Presione Enter para generar uno aleatorio: $GENERATED_TOKEN]: " SYNC_TOKEN
|
||||||
|
SYNC_TOKEN=${SYNC_TOKEN:-$GENERATED_TOKEN}
|
||||||
|
|
||||||
|
if [ "$SERVER_ROLE" == "1" ]; then
|
||||||
|
echo -e "${GREEN}Configurando como HUB...${NC}"
|
||||||
|
CENTRAL_URL=""
|
||||||
|
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:8000/api/v1/core/help-center/sync/]: " CENTRAL_URL
|
||||||
|
CENTRAL_URL=${CENTRAL_URL:-http://100.78.6.108:8001/api/v1/core/help-center/sync/}
|
||||||
|
SPOKE_URLS=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Aplicar configuraciones
|
||||||
|
sed -i "s|^CENTRAL_SERVER_URL=.*|CENTRAL_SERVER_URL=$CENTRAL_URL|" .env 2>/dev/null || echo "CENTRAL_SERVER_URL=$CENTRAL_URL" >> .env
|
||||||
|
sed -i "s|^SPOKE_URLS=.*|SPOKE_URLS=$SPOKE_URLS|" .env 2>/dev/null || echo "SPOKE_URLS=$SPOKE_URLS" >> .env
|
||||||
|
if ! grep -q "SYNC_SECRET_TOKEN=" .env; then
|
||||||
|
echo "SYNC_SECRET_TOKEN=$SYNC_TOKEN" >> .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
echo -e "${GREEN}✓ Configuración de rol aplicada al .env${NC}"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW}⚠ .env ya existe, no se sobrescribirá${NC}"
|
echo -e "${YELLOW}⚠ .env ya contiene configuración de rol. Para cambiarlo, ejecuta con RECONFIGURE=true${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 3. Limpiar contenedores previos si existen
|
# 3. Limpiar contenedores previos si existen
|
||||||
|
|||||||
Reference in New Issue
Block a user