Implementacion de sistema distribuido de boton de ayuda
This commit is contained in:
@@ -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/
|
||||||
|
|||||||
22
backend/api/v1/modules/core/help_center/models.py
Normal file
22
backend/api/v1/modules/core/help_center/models.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Column, String, Text, DateTime
|
||||||
|
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)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<HelpArticle(title='{self.title}', slug='{self.slug}')>"
|
||||||
116
backend/api/v1/modules/core/help_center/routes.py
Normal file
116
backend/api/v1/modules/core/help_center/routes.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.config import settings
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
# 1. Upstream Sync (Client -> Hub)
|
||||||
|
if settings.CENTRAL_SERVER_URL:
|
||||||
|
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 and settings.SPOKE_URLS:
|
||||||
|
from .tasks import broadcast_help_update
|
||||||
|
# origin_client_uuid is None because this change originated on the Hub itself
|
||||||
|
broadcast_help_update.delay(str(article_uuid), None)
|
||||||
|
|
||||||
|
@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.get("/articles/", response_model=List[HelpArticleInDB])
|
||||||
|
def list_articles(db: Session = Depends(get_core_db)):
|
||||||
|
"""Lista todos los artículos de ayuda."""
|
||||||
|
return HelpCenterService.get_all(db)
|
||||||
|
|
||||||
|
@router.get("/modifications/", response_model=List[HelpArticleInDB])
|
||||||
|
def get_modifications(since: datetime, db: Session = Depends(get_core_db)):
|
||||||
|
"""Obtiene artículos modificados desde la fecha indicada (Polling)."""
|
||||||
|
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)):
|
||||||
|
"""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)):
|
||||||
|
"""Crea un nuevo artículo."""
|
||||||
|
new_article = HelpCenterService.create(db, article)
|
||||||
|
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)):
|
||||||
|
"""Actualiza un artículo."""
|
||||||
|
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)):
|
||||||
|
"""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
|
||||||
43
backend/api/v1/modules/core/help_center/schemas.py
Normal file
43
backend/api/v1/modules/core/help_center/schemas.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
class HelpArticleCreate(HelpArticleBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class HelpArticleUpdate(BaseModel):
|
||||||
|
slug: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
last_editor: 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
|
||||||
|
origin_client_uuid: Optional[UUID] = 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
|
||||||
|
message: str
|
||||||
116
backend/api/v1/modules/core/help_center/services.py
Normal file
116
backend/api/v1/modules/core/help_center/services.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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 get_all(db: Session) -> List[HelpArticle]:
|
||||||
|
return db.query(HelpArticle).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_uuid(db: Session, article_uuid: UUID) -> Optional[HelpArticle]:
|
||||||
|
return db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_slug(db: Session, slug: str) -> Optional[HelpArticle]:
|
||||||
|
return db.query(HelpArticle).filter(HelpArticle.slug == slug).first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_modifications(db: Session, since: datetime) -> List[HelpArticle]:
|
||||||
|
# Ensure timezone awareness
|
||||||
|
if since.tzinfo is None:
|
||||||
|
since = since.replace(tzinfo=timezone.utc)
|
||||||
|
return db.query(HelpArticle).filter(HelpArticle.updated_at > since).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create(db: Session, article: HelpArticleCreate) -> HelpArticle:
|
||||||
|
db_article = HelpArticle(**article.model_dump())
|
||||||
|
db.add(db_article)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_article)
|
||||||
|
return 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
|
||||||
|
|
||||||
|
update_data = article_data.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(db_article, key, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_article)
|
||||||
|
return 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).
|
||||||
|
"""
|
||||||
|
# Loop Prevention: If the update originated from this client, ignore it.
|
||||||
|
from core.config import settings
|
||||||
|
if settings.CLIENT_UUID and sync_data.origin_client_uuid and str(sync_data.origin_client_uuid) == settings.CLIENT_UUID:
|
||||||
|
return HelpSyncResponse(status="OK", message="Skipped: I am the origin.")
|
||||||
|
|
||||||
|
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
|
||||||
|
new_article = HelpArticle(
|
||||||
|
uuid=sync_data.article_uuid,
|
||||||
|
slug=sync_data.client_slug,
|
||||||
|
title=sync_data.client_title,
|
||||||
|
content=sync_data.client_content,
|
||||||
|
updated_at=client_updated_at,
|
||||||
|
last_editor=sync_data.last_editor
|
||||||
|
)
|
||||||
|
db.add(new_article)
|
||||||
|
db.commit()
|
||||||
|
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:
|
||||||
|
db_article.content = sync_data.client_content
|
||||||
|
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.commit()
|
||||||
|
return HelpSyncResponse(status="OK", message="Server updated with client data.")
|
||||||
|
|
||||||
|
# Caso B: Servidor es más nuevo
|
||||||
|
elif server_updated_at > client_updated_at:
|
||||||
|
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,
|
||||||
|
message="Client is outdated. Update required."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Caso C: Iguales
|
||||||
|
else:
|
||||||
|
return HelpSyncResponse(status="OK", message="Already in sync.")
|
||||||
236
backend/api/v1/modules/core/help_center/tasks.py
Normal file
236
backend/api/v1/modules/core/help_center/tasks.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
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, origin_client_uuid_str: str = None):
|
||||||
|
"""
|
||||||
|
Difunde una actualización de artículo a todos los spokes configurados,
|
||||||
|
excepto al que originó el cambio (si existe).
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
origin_client_uuid=UUID(origin_client_uuid_str) if origin_client_uuid_str else None
|
||||||
|
).model_dump(mode='json')
|
||||||
|
|
||||||
|
with httpx.Client() as client:
|
||||||
|
for spoke_url in spokes:
|
||||||
|
# Loop Prevention: Skip if the spoke is the origin
|
||||||
|
# NOTE: This assumes SPOKE_URLS contains the unique Sync URL of the client.
|
||||||
|
# A more robust check would be comparing CLIENT_UUID if we knew the URL->UUID mapping ahead of time.
|
||||||
|
# For this implementation, we rely on the `origin_client_uuid` being passed in the payload
|
||||||
|
# and the receiving client checking it against its own settings.CLIENT_UUID.
|
||||||
|
|
||||||
|
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,
|
||||||
|
origin_client_uuid=UUID(settings.CLIENT_UUID) if settings.CLIENT_UUID else None
|
||||||
|
)
|
||||||
|
|
||||||
|
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.")
|
||||||
|
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:
|
||||||
|
# Convertir string a datetime
|
||||||
|
server_updated_at = datetime.fromisoformat(art_data['updated_at'])
|
||||||
|
|
||||||
|
# Logic similar to sync_article but simpler (Force Update from Hub)
|
||||||
|
# We assume Hub is Truth in this Polling flow
|
||||||
|
local_article = db.query(HelpArticle).filter(HelpArticle.uuid == art_data['uuid']).first()
|
||||||
|
|
||||||
|
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']
|
||||||
|
)
|
||||||
|
db.add(new_article)
|
||||||
|
else:
|
||||||
|
if server_updated_at > local_article.updated_at.replace(tzinfo=timezone.utc):
|
||||||
|
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']
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
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()
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -3,18 +3,25 @@ from celery import Celery
|
|||||||
|
|
||||||
|
|
||||||
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",
|
||||||
"api.v1.modules.a76.reports.importacion.packing_list.task",
|
"api.v1.modules.a76.reports.importacion.packing_list.task",
|
||||||
"api.v1.modules.a76.reports.exportacion.aviso_consolidado.task",
|
"api.v1.modules.a76.reports.exportacion.aviso_consolidado.task",
|
||||||
"api.v1.modules.a76.reports.exportacion.descargo.task",
|
"api.v1.modules.a76.reports.exportacion.descargo.task",
|
||||||
"api.v1.modules.a76.imports.tasks"
|
"api.v1.modules.a76.imports.tasks",
|
||||||
|
"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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,5 +35,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,12 @@ 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)
|
||||||
|
CLIENT_UUID: str = "" # Unique identifier for this Client instance (Spoke only)
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||||
|
|
||||||
@@ -57,6 +64,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"""
|
||||||
|
|||||||
@@ -20,6 +20,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
|
||||||
@@ -74,6 +75,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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -174,6 +174,10 @@ 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:-dev-secret}
|
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret}
|
||||||
|
- SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-d2Navi4c3cdrdK92oE3iiGuXn0T4dY9k}
|
||||||
|
- CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL} # Variable clave para definir si es Cliente o Hub
|
||||||
|
- SPOKE_URLS=${SPOKE_URLS}
|
||||||
|
- CLIENT_UUID=${CLIENT_UUID}
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000}
|
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000}
|
||||||
- SITAR_API_URL=${SITAR_API_URL}
|
- SITAR_API_URL=${SITAR_API_URL}
|
||||||
- SITAR_API_USER=${SITAR_API_USER}
|
- SITAR_API_USER=${SITAR_API_USER}
|
||||||
@@ -270,9 +274,13 @@ services:
|
|||||||
celery_worker:
|
celery_worker:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: worker
|
container_name: worker
|
||||||
command: celery -A core.celery_app worker --loglevel=info
|
command: celery -A core.celery_app worker --loglevel=info -B
|
||||||
environment:
|
environment:
|
||||||
- VALKEY_URL=redis://valkey:6379/0
|
- VALKEY_URL=redis://valkey:6379/0
|
||||||
|
- CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL}
|
||||||
|
- SPOKE_URLS=${SPOKE_URLS}
|
||||||
|
- CLIENT_UUID=${CLIENT_UUID}
|
||||||
|
- SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-d2Navi4c3cdrdK92oE3iiGuXn0T4dY9k}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
- valkey
|
- valkey
|
||||||
|
|||||||
68
frontend/src/lib/api/help.ts
Normal file
68
frontend/src/lib/api/help.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { 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 getHeaders() {
|
||||||
|
const auth = get(authStore);
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(auth.token ? { 'Authorization': `Bearer ${auth.token}` } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelpArticle {
|
||||||
|
uuid: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
updated_at: string;
|
||||||
|
last_editor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const helpApi = {
|
||||||
|
async listArticles(): Promise<HelpArticle[]> {
|
||||||
|
const response = await fetch(`${BASE_URL}/articles/`);
|
||||||
|
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}/`);
|
||||||
|
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.ok) throw new Error('Failed to update article');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async createArticle(data: { title: string; content: string; slug: string; last_editor: string }): Promise<HelpArticle> {
|
||||||
|
const response = await fetch(`${BASE_URL}/articles/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to create article');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteArticle(uuid: string): Promise<void> {
|
||||||
|
const response = await fetch(`${BASE_URL}/articles/${uuid}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to delete article');
|
||||||
|
},
|
||||||
|
|
||||||
|
async triggerSync(): Promise<void> {
|
||||||
|
// Opcional: endpoint para forzar sync desde UI si es necesario
|
||||||
|
}
|
||||||
|
};
|
||||||
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>
|
||||||
@@ -503,7 +503,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>
|
||||||
|
|||||||
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?.()}
|
||||||
|
|||||||
321
frontend/src/routes/dashboard/help-center/+page.svelte
Normal file
321
frontend/src/routes/dashboard/help-center/+page.svelte
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<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 } from 'lucide-svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let articles: HelpArticle[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let searchTerm = $state('');
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredArticles = $derived(
|
||||||
|
articles.filter(
|
||||||
|
(a) =>
|
||||||
|
a.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
a.content.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
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 resetForm() {
|
||||||
|
formValues = {
|
||||||
|
title: '',
|
||||||
|
slug: '',
|
||||||
|
content: '',
|
||||||
|
last_editor: 'Admin'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
processing = true;
|
||||||
|
try {
|
||||||
|
// Generate slug if empty
|
||||||
|
if (!formValues.slug) {
|
||||||
|
formValues.slug = formValues.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ /g, '-')
|
||||||
|
.replace(/[^\w-]+/g, '');
|
||||||
|
}
|
||||||
|
await helpApi.createArticle(formValues);
|
||||||
|
toast.success('Artículo creado correctamente');
|
||||||
|
showCreateModal = false;
|
||||||
|
resetForm();
|
||||||
|
await loadArticles();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('Error: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdate() {
|
||||||
|
if (!selectedArticle) return;
|
||||||
|
processing = true;
|
||||||
|
try {
|
||||||
|
await helpApi.updateArticle(selectedArticle.uuid, formValues);
|
||||||
|
toast.success('Artículo actualizado');
|
||||||
|
showEditModal = false;
|
||||||
|
await loadArticles();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('Error: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!selectedArticle) return;
|
||||||
|
processing = true;
|
||||||
|
try {
|
||||||
|
await helpApi.deleteArticle(selectedArticle.uuid);
|
||||||
|
toast.success('Artículo eliminado');
|
||||||
|
showDeleteDialog = false;
|
||||||
|
await loadArticles();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('Error: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(article: HelpArticle) {
|
||||||
|
selectedArticle = article;
|
||||||
|
formValues = {
|
||||||
|
title: article.title,
|
||||||
|
slug: article.slug,
|
||||||
|
content: article.content,
|
||||||
|
last_editor: article.last_editor
|
||||||
|
};
|
||||||
|
showEditModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete(article: HelpArticle) {
|
||||||
|
selectedArticle = article;
|
||||||
|
showDeleteDialog = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col space-y-6">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="flex flex-col gap-4 px-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight">Centro de Ayuda</h1>
|
||||||
|
<p class="text-muted-foreground">Gestiona la base de conocimientos distribuida.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onclick={() => {
|
||||||
|
resetForm();
|
||||||
|
showCreateModal = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
Nuevo Artículo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters & Tools -->
|
||||||
|
<div class="flex items-center gap-2 px-2">
|
||||||
|
<div class="relative max-w-sm flex-1">
|
||||||
|
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input type="search" placeholder="Buscar artículos..." class="pl-8" bind:value={searchTerm} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 overflow-auto p-2">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex h-32 items-center justify-center">
|
||||||
|
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
{:else if filteredArticles.length === 0}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content class="flex flex-col items-center justify-center py-10">
|
||||||
|
<p class="text-muted-foreground">No se encontraron artículos.</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{:else}
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each filteredArticles as article}
|
||||||
|
<Card.Root class="flex h-full flex-col transition-colors hover:border-primary/50">
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<Card.Title class="line-clamp-2 text-lg">{article.title}</Card.Title>
|
||||||
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
onclick={() => openEdit(article)}
|
||||||
|
>
|
||||||
|
<Edit class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onclick={() => openDelete(article)}
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Card.Description class="text-xs">
|
||||||
|
Slug: /{article.slug}
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="flex-grow">
|
||||||
|
<div class="prose prose-sm line-clamp-4 max-w-none text-sm text-muted-foreground">
|
||||||
|
{@html article.content}
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
<Card.Footer
|
||||||
|
class="flex justify-between bg-muted/30 pt-3 text-[10px] tracking-wider text-muted-foreground uppercase"
|
||||||
|
>
|
||||||
|
<span>Edito: {article.last_editor}</span>
|
||||||
|
<span>{new Date(article.updated_at).toLocaleDateString()}</span>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Modal -->
|
||||||
|
<Dialog.Root bind:open={showCreateModal}>
|
||||||
|
<Dialog.Content class="sm:max-w-[600px]">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Crear Artículo</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Completa los campos para añadir un nuevo artículo a la base de conocimientos.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<div class="grid gap-4 py-4">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="title">Título</Label>
|
||||||
|
<Input id="title" bind:value={formValues.title} placeholder="Ej: Cómo subir facturas" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="slug">Slug (URL)</Label>
|
||||||
|
<Input id="slug" bind:value={formValues.slug} placeholder="ej-como-subir-facturas" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="content">Contenido (HTML permitido)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="content"
|
||||||
|
bind:value={formValues.content}
|
||||||
|
rows={10}
|
||||||
|
placeholder="Contenido del artículo..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (showCreateModal = false)}>Cancelar</Button>
|
||||||
|
<Button
|
||||||
|
onclick={handleCreate}
|
||||||
|
disabled={processing || !formValues.title || !formValues.content}
|
||||||
|
>
|
||||||
|
{#if processing}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
|
||||||
|
Guardar Artículo
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
<Dialog.Root bind:open={showEditModal}>
|
||||||
|
<Dialog.Content class="sm:max-w-[600px]">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Editar Artículo</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Modifica el contenido del artículo. Los cambios se sincronizarán automáticamente.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<div class="grid gap-4 py-4">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="edit-title">Título</Label>
|
||||||
|
<Input id="edit-title" bind:value={formValues.title} />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="edit-slug">Slug (URL)</Label>
|
||||||
|
<Input id="edit-slug" bind:value={formValues.slug} />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="edit-content">Contenido</Label>
|
||||||
|
<Textarea id="edit-content" bind:value={formValues.content} rows={10} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (showEditModal = false)}>Cancelar</Button>
|
||||||
|
<Button
|
||||||
|
onclick={handleUpdate}
|
||||||
|
disabled={processing || !formValues.title || !formValues.content}
|
||||||
|
>
|
||||||
|
{#if processing}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
|
||||||
|
Actualizar Cambios
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation -->
|
||||||
|
<AlertDialog.Root bind:open={showDeleteDialog}>
|
||||||
|
<AlertDialog.Content>
|
||||||
|
<AlertDialog.Header>
|
||||||
|
<AlertDialog.Title>¿Estás seguro?</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>
|
||||||
|
Esta acción eliminará el artículo "{selectedArticle?.title}" de forma permanente. Esta
|
||||||
|
acción no se puede deshacer.
|
||||||
|
</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 Artículo
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</AlertDialog.Footer>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Root>
|
||||||
@@ -65,6 +65,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"
|
||||||
@@ -337,8 +348,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 puerto 5174
|
||||||
|
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 (Puerto 5174 agregado)${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}" \
|
||||||
@@ -357,10 +407,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": {
|
||||||
@@ -516,8 +568,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..."
|
||||||
@@ -552,7 +605,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
|
||||||
@@ -580,8 +634,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
|
||||||
@@ -604,7 +659,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}"
|
||||||
@@ -624,11 +680,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
|
||||||
|
|||||||
Reference in New Issue
Block a user