diff --git a/services/cms-service/migrations/20260216000004_content_libraries.sql b/services/cms-service/migrations/20260216000004_content_libraries.sql new file mode 100644 index 0000000..3aba61c --- /dev/null +++ b/services/cms-service/migrations/20260216000004_content_libraries.sql @@ -0,0 +1,51 @@ +-- Content Libraries: Repositorio reutilizable de bloques y lecciones +-- Permite a instructores guardar y reutilizar componentes de contenido + +-- Bloques reutilizables guardados en la biblioteca +CREATE TABLE library_blocks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + created_by UUID NOT NULL, -- El instructor que lo guardó + name TEXT NOT NULL, -- Nombre descriptivo dado por el instructor + description TEXT, -- Descripción opcional + block_type TEXT NOT NULL, -- 'quiz', 'peer-review', 'hotspot', etc. + block_data JSONB NOT NULL, -- El bloque completo (mismo formato que metadata.blocks) + tags TEXT[], -- Array de etiquetas para búsqueda + usage_count INTEGER DEFAULT 0, -- Contador de cuántas veces se ha usado + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices para búsqueda eficiente +CREATE INDEX idx_library_blocks_org ON library_blocks(organization_id); +CREATE INDEX idx_library_blocks_type ON library_blocks(block_type); +CREATE INDEX idx_library_blocks_tags ON library_blocks USING GIN(tags); +CREATE INDEX idx_library_blocks_created_by ON library_blocks(created_by); + +-- Trigger para actualizar updated_at +CREATE TRIGGER update_library_blocks_updated_at + BEFORE UPDATE ON library_blocks + FOR EACH ROW + EXECUTE PROCEDURE update_updated_at_column(); + +-- Plantillas de lecciones completas (para futuras expansiones) +CREATE TABLE library_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + created_by UUID NOT NULL, + name TEXT NOT NULL, + description TEXT, + lesson_data JSONB NOT NULL, -- Incluye metadata.blocks y configuración + tags TEXT[], + usage_count INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_library_templates_org ON library_templates(organization_id); + +-- Trigger para actualizar updated_at +CREATE TRIGGER update_library_templates_updated_at + BEFORE UPDATE ON library_templates + FOR EACH ROW + EXECUTE PROCEDURE update_updated_at_column(); diff --git a/services/cms-service/src/handlers_library.rs b/services/cms-service/src/handlers_library.rs new file mode 100644 index 0000000..06eb922 --- /dev/null +++ b/services/cms-service/src/handlers_library.rs @@ -0,0 +1,238 @@ +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, +}; +use common::models::{CreateLibraryBlockPayload, LibraryBlock, UpdateLibraryBlockPayload}; +use common::{auth::Claims, middleware::Org}; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub struct LibraryBlockFilters { + #[serde(rename = "type")] + pub block_type: Option, + pub tags: Option, // Comma-separated list + pub search: Option, +} + +/// POST /api/library/blocks - Guardar un bloque en la biblioteca +pub async fn create_library_block( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let block = sqlx::query_as!( + LibraryBlock, + r#" + INSERT INTO library_blocks (organization_id, created_by, name, description, block_type, block_data, tags) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, organization_id, created_by, name, description, block_type, block_data, tags, usage_count as "usage_count!", created_at, updated_at + "#, + org_ctx.id, + claims.sub, + payload.name, + payload.description, + payload.block_type, + payload.block_data, + payload.tags.as_deref() + ) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(block)) +} + +/// GET /api/library/blocks - Listar bloques de la biblioteca +pub async fn list_library_blocks( + Org(org_ctx): Org, + State(pool): State, + Query(filters): Query, +) -> Result>, (StatusCode, String)> { + // Base query + let mut query = String::from("SELECT * FROM library_blocks WHERE organization_id = $1"); + let mut param_count = 1; + + // Filtro por tipo + if filters.block_type.is_some() { + param_count += 1; + query.push_str(&format!(" AND block_type = ${}", param_count)); + } + + // Filtro por tags (busca si algún tag coincide) + if filters.tags.is_some() { + param_count += 1; + query.push_str(&format!(" AND tags && ${}", param_count)); + } + + // Búsqueda en nombre y descripción + if filters.search.is_some() { + param_count += 1; + query.push_str(&format!( + " AND (name ILIKE ${0} OR description ILIKE ${0})", + param_count + )); + } + + query.push_str(" ORDER BY created_at DESC"); + + // Build query con bind dinámico + let mut sql_query = sqlx::query_as::<_, LibraryBlock>(&query).bind(org_ctx.id); + + if let Some(block_type) = &filters.block_type { + sql_query = sql_query.bind(block_type); + } + + if let Some(tags_str) = &filters.tags { + let tags: Vec = tags_str.split(',').map(|s| s.trim().to_string()).collect(); + sql_query = sql_query.bind(tags); + } + + let search_pattern = filters.search.as_ref().map(|s| format!("%{}%", s)); + if let Some(ref pattern) = search_pattern { + sql_query = sql_query.bind(pattern); + } + + let blocks = sql_query + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(blocks)) +} + +/// GET /api/library/blocks/:id - Obtener un bloque específico +pub async fn get_library_block( + Org(org_ctx): Org, + State(pool): State, + Path(block_id): Path, +) -> Result, (StatusCode, String)> { + let block = sqlx::query_as!( + LibraryBlock, + r#"SELECT id, organization_id, created_by, name, description, block_type, block_data, tags, usage_count as "usage_count!", created_at, updated_at FROM library_blocks WHERE id = $1 AND organization_id = $2"#, + block_id, + org_ctx.id + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + match block { + Some(b) => Ok(Json(b)), + None => Err((StatusCode::NOT_FOUND, "Block not found".to_string())), + } +} + +/// PUT /api/library/blocks/:id - Actualizar bloque (nombre, descripción, tags) +pub async fn update_library_block( + Org(org_ctx): Org, + State(pool): State, + Path(block_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + // Verificar que el bloque existe y pertenece a la org + let existing = sqlx::query!( + "SELECT id FROM library_blocks WHERE id = $1 AND organization_id = $2", + block_id, + org_ctx.id + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if existing.is_none() { + return Err((StatusCode::NOT_FOUND, "Block not found".to_string())); + } + + // Update dinámico basado en campos provistos + let updated = if let Some(name) = &payload.name { + sqlx::query_as!( + LibraryBlock, + r#" + UPDATE library_blocks + SET name = COALESCE($1, name), + description = COALESCE($2, description), + tags = COALESCE($3, tags), + updated_at = NOW() + WHERE id = $4 AND organization_id = $5 + RETURNING id, organization_id, created_by, name, description, block_type, block_data, tags, usage_count as "usage_count!", created_at, updated_at + "#, + Some(name), + payload.description, + payload.tags.as_deref(), + block_id, + org_ctx.id + ) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + } else { + sqlx::query_as!( + LibraryBlock, + r#" + UPDATE library_blocks + SET description = COALESCE($1, description), + tags = COALESCE($2, tags), + updated_at = NOW() + WHERE id = $3 AND organization_id = $4 + RETURNING id, organization_id, created_by, name, description, block_type, block_data, tags, usage_count as "usage_count!", created_at, updated_at + "#, + payload.description, + payload.tags.as_deref(), + block_id, + org_ctx.id + ) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + }; + + Ok(Json(updated)) +} + +/// DELETE /api/library/blocks/:id - Eliminar bloque +pub async fn delete_library_block( + Org(org_ctx): Org, + State(pool): State, + Path(block_id): Path, +) -> Result { + let result = sqlx::query!( + "DELETE FROM library_blocks WHERE id = $1 AND organization_id = $2", + block_id, + org_ctx.id + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Block not found".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +/// POST /api/library/blocks/:id/increment-usage - Incrementar contador de uso +pub async fn increment_block_usage( + Org(org_ctx): Org, + State(pool): State, + Path(block_id): Path, +) -> Result { + let result = sqlx::query!( + "UPDATE library_blocks SET usage_count = usage_count + 1 WHERE id = $1 AND organization_id = $2", + block_id, + org_ctx.id + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Block not found".to_string())); + } + + Ok(StatusCode::OK) +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 17664a4..f6e97cc 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -1,9 +1,10 @@ mod db_util; pub mod exporter; +mod external_handlers; mod handlers; mod handlers_branding; +mod handlers_library; mod webhooks; -mod external_handlers; use axum::{ Router, @@ -139,7 +140,10 @@ async fn main() { get(handlers::get_grading_categories), ) .route("/auth/me", get(handlers::get_me)) - .route("/users", get(handlers::get_all_users).post(handlers::admin_create_user)) + .route( + "/users", + get(handlers::get_all_users).post(handlers::admin_create_user), + ) .route("/users/{id}", axum::routing::put(handlers::update_user)) .route("/audit-logs", get(handlers::get_audit_logs)) .route("/api/ai/review-text", post(handlers::review_text)) @@ -177,14 +181,38 @@ async fn main() { "/organizations/{id}/branding", axum::routing::put(handlers_branding::update_organization_branding), ) + // Content Libraries routes + .route( + "/library/blocks", + get(handlers_library::list_library_blocks).post(handlers_library::create_library_block), + ) + .route( + "/library/blocks/{id}", + get(handlers_library::get_library_block) + .put(handlers_library::update_library_block) + .delete(handlers_library::delete_library_block), + ) + .route( + "/library/blocks/{id}/increment-usage", + post(handlers_library::increment_block_usage), + ) .route_layer(middleware::from_fn( common::middleware::org_extractor_middleware, )); let api_routes = Router::new() - .route("/v1/courses", post(external_handlers::create_course_external)) - .route("/v1/courses/{id}", get(external_handlers::get_course_external)) - .route("/v1/lessons/{id}/transcribe", post(external_handlers::trigger_transcription_external)); + .route( + "/v1/courses", + post(external_handlers::create_course_external), + ) + .route( + "/v1/courses/{id}", + get(external_handlers::get_course_external), + ) + .route( + "/v1/lessons/{id}/transcribe", + post(external_handlers::trigger_transcription_external), + ); // Rutas públicas que no requieren autenticación let public_routes = Router::new() diff --git a/services/lms-service/src/handlers_peer_review.rs b/services/lms-service/src/handlers_peer_review.rs index d710047..deeedd9 100644 --- a/services/lms-service/src/handlers_peer_review.rs +++ b/services/lms-service/src/handlers_peer_review.rs @@ -178,7 +178,7 @@ pub async fn submit_peer_review( } pub async fn get_my_submission_feedback( - Org(org_ctx): Org, + Org(_org_ctx): Org, claims: Claims, State(pool): State, Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>, diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index e01c0d2..2397b18 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -525,7 +525,52 @@ pub struct SubmitAssignmentPayload { pub struct SubmitPeerReviewPayload { pub submission_id: Uuid, pub score: i32, - pub feedback: String, +} + +// Content Libraries +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct LibraryBlock { + pub id: Uuid, + pub organization_id: Uuid, + pub created_by: Uuid, + pub name: String, + pub description: Option, + pub block_type: String, + pub block_data: serde_json::Value, + pub tags: Option>, + pub usage_count: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateLibraryBlockPayload { + pub name: String, + pub description: Option, + pub block_type: String, + pub block_data: serde_json::Value, + pub tags: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateLibraryBlockPayload { + pub name: Option, + pub description: Option, + pub tags: Option>, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct LibraryTemplate { + pub id: Uuid, + pub organization_id: Uuid, + pub created_by: Uuid, + pub name: String, + pub description: Option, + pub lesson_data: serde_json::Value, + pub tags: Option>, + pub usage_count: i32, + pub created_at: DateTime, + pub updated_at: DateTime, } #[cfg(test)] diff --git a/web/studio/src/components/LibraryPanel.tsx b/web/studio/src/components/LibraryPanel.tsx new file mode 100644 index 0000000..716bbfd --- /dev/null +++ b/web/studio/src/components/LibraryPanel.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { LibraryBlock, api } from '@/lib/api'; + +interface LibraryPanelProps { + isOpen: boolean; + onClose: () => void; + onSelectBlock: (block: LibraryBlock) => void; +} + +export default function LibraryPanel({ isOpen, onClose, onSelectBlock }: LibraryPanelProps) { + const [blocks, setBlocks] = useState([]); + const [filteredBlocks, setFilteredBlocks] = useState([]); + const [loading, setLoading] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedType, setSelectedType] = useState(''); + const [selectedTag, setSelectedTag] = useState(''); + + // Get unique types and tags from blocks + const blockTypes = Array.from(new Set(blocks.map(b => b.block_type))); + const allTags = Array.from(new Set(blocks.flatMap(b => b.tags || []))); + + useEffect(() => { + if (isOpen) { + loadBlocks(); + } + }, [isOpen]); + + useEffect(() => { + filterBlocks(); + }, [blocks, searchTerm, selectedType, selectedTag]); + + const loadBlocks = async () => { + setLoading(true); + try { + const data = await api.listLibraryBlocks(); + setBlocks(data); + } catch (error) { + console.error('Error loading library blocks:', error); + } finally { + setLoading(false); + } + }; + + const filterBlocks = () => { + let filtered = [...blocks]; + + if (searchTerm) { + const term = searchTerm.toLowerCase(); + filtered = filtered.filter( + b => b.name.toLowerCase().includes(term) || + b.description?.toLowerCase().includes(term) + ); + } + + if (selectedType) { + filtered = filtered.filter(b => b.block_type === selectedType); + } + + if (selectedTag) { + filtered = filtered.filter(b => b.tags?.includes(selectedTag)); + } + + setFilteredBlocks(filtered); + }; + + const handleUseBlock = async (block: LibraryBlock) => { + try { + // Increment usage counter + await api.incrementBlockUsage(block.id); + onSelectBlock(block); + onClose(); + } catch (error) { + console.error('Error using block:', error); + } + }; + + const handleDeleteBlock = async (blockId: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (!confirm('¿Estás seguro de eliminar este bloque de la biblioteca?')) return; + + try { + await api.deleteLibraryBlock(blockId); + setBlocks(blocks.filter(b => b.id !== blockId)); + } catch (error) { + console.error('Error deleting block:', error); + alert('Error al eliminar el bloque'); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+

Biblioteca de Bloques

+ +
+ + {/* Filters */} +
+ {/* Search */} + setSearchTerm(e.target.value)} + className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + {/* Type Filter */} + + + {/* Tag Filter */} + +
+
+ + {/* Content */} +
+ {loading ? ( +
+
Cargando...
+
+ ) : filteredBlocks.length === 0 ? ( +
+ + + +

No se encontraron bloques

+
+ ) : ( +
+ {filteredBlocks.map((block) => ( +
+
+
+

{block.name}

+ + {block.block_type} + +
+ +
+ + {block.description && ( +

{block.description}

+ )} + + {block.tags && block.tags.length > 0 && ( +
+ {block.tags.map((tag) => ( + + #{tag} + + ))} +
+ )} + +
+ + Usado {block.usage_count} {block.usage_count === 1 ? 'vez' : 'veces'} + + +
+
+ ))} +
+ )} +
+ + {/* Footer */} +
+
+ {filteredBlocks.length} bloque(s) encontrado(s) + +
+
+
+
+ ); +} diff --git a/web/studio/src/components/modals/SaveToLibraryModal.tsx b/web/studio/src/components/modals/SaveToLibraryModal.tsx new file mode 100644 index 0000000..de566b3 --- /dev/null +++ b/web/studio/src/components/modals/SaveToLibraryModal.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useState } from 'react'; +import { Block } from '@/lib/api'; + +interface SaveToLibraryModalProps { + block: Block; + isOpen: boolean; + onClose: () => void; + onSave: (name: string, description: string, tags: string[]) => Promise; +} + +export default function SaveToLibraryModal({ + block, + isOpen, + onClose, + onSave, +}: SaveToLibraryModalProps) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [tagInput, setTagInput] = useState(''); + const [tags, setTags] = useState([]); + const [isSaving, setIsSaving] = useState(false); + + if (!isOpen) return null; + + const handleAddTag = () => { + const trimmed = tagInput.trim(); + if (trimmed && !tags.includes(trimmed)) { + setTags([...tags, trimmed]); + setTagInput(''); + } + }; + + const handleRemoveTag = (tagToRemove: string) => { + setTags(tags.filter(t => t !== tagToRemove)); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + + setIsSaving(true); + try { + await onSave(name, description, tags); + // Reset form + setName(''); + setDescription(''); + setTags([]); + setTagInput(''); + onClose(); + } catch (error) { + console.error('Error saving to library:', error); + alert('Error al guardar en la biblioteca. Por favor intenta de nuevo.'); + } finally { + setIsSaving(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && tagInput.trim()) { + e.preventDefault(); + handleAddTag(); + } + }; + + return ( +
+
+
+

Guardar en Biblioteca

+ +
+ {/* Block Type Badge */} +
+ + {block.type} + +
+ + {/* Name Input */} +
+ + setName(e.target.value)} + placeholder="ej: Quiz de Matemáticas - Álgebra" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> +
+ + {/* Description Input */} +
+ +