feat: add PluginBlock component for rendering external web components in sandboxed iframes

feat: implement PluginsPage for managing plugins with create, toggle, and delete functionalities

feat: create PedagogicalAnalyticsPage for displaying course analytics including quality metrics, discrimination index, and curricular suggestions

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-27 12:22:05 -04:00
parent 675fa1e299
commit f6a3f6aedf
23 changed files with 2580 additions and 24 deletions
+10 -11
View File
@@ -96,19 +96,19 @@
- [x] **Ética de Datos**: Herramientas para transparencia en el uso de datos por los modelos de IA local. *(MVP backend + UI Studio: endpoint protegido `/ai/data-ethics/summary` con métricas de uso, eventos recientes y campos almacenados; panel Admin en `/admin/data-ethics`.)*
### Fase 33: Aprendizaje Colaborativo Síncrono 🤝
- [ ] **Pizarras Compartidas**: Espacio de dibujo colaborativo integrado en lecciones.
- [x] **Pizarras Compartidas**: Espacio de dibujo colaborativo integrado en lecciones. *(MVP completo: backend REST + UI Experience con polling, autosave debounce 1.5s, control de conflictos optimista `revision`/`409` y panel de resolución con diff local vs remoto.)*
- [ ] **Edición Multiusuario**: Soporte para documentos compartidos en tiempo real (tipo Google Docs).
- [ ] **Salas de Estudio**: Grupos efímeros para resolución de dudas grupales por video.
### Fase 34: Análisis Pedagógico Profundo 📊
- [ ] **Métricas de Calidad**: Análisis automático de la efectividad de las lecciones generadas.
- [ ] **Índice de Discriminación**: Estadísticas sobre qué preguntas de quiz discriminan mejor el conocimiento.
- [ ] **Sugerencias Curriculares**: IA recomendando cambios en la estructura del curso basada en el rendimiento real.
- [x] **Métricas de Calidad**: Análisis automático de la efectividad de las lecciones (completion_rate, failure_rate, abandonment, avg_attempts). *(Backend `GET /courses/{id}/pedagogical/quality-metrics` + UI Studio con barras proporcionales.)*
- [x] **Índice de Discriminación**: Estadísticas sobre qué preguntas de quiz discriminan mejor el conocimiento. *(Backend `GET /courses/{id}/pedagogical/discrimination-index` con agrupación por `metadata.block_scores` + clasificación Excelente/Buena/Aceptable/Revisar.)*
- [x] **Sugerencias Curriculares**: Reglas automáticas (5 tipos) recomendando cambios en la estructura del curso basada en rendimiento real. *(Backend `GET /courses/{id}/pedagogical/suggestions` + panel Studio con severidad alta/media/info/positivo.)*
### Fase 35: Ecosistema de Plugins 🔌
- [ ] **Arquitectura Modular**: Sistema para que desarrolladores externos agreguen nuevos "Content Blocks".
- [ ] **Soporte para Web Components**: Permitir la inclusión de herramientas interactivas externas de forma segura.
- [ ] **OpenCCB Market**: Galería interna para descargar y habilitar extensiones.
- [x] **Arquitectura Modular**: Tabla `org_plugins` con CRUD completo en `cms-service` (`GET /plugins`, `POST /plugins`, `PUT /plugins/{id}`, `DELETE /plugins/{id}`). Validación HTTPS obligatoria.
- [x] **Soporte para Web Components**: Bloque `plugin` en Experience carga el componente en `<iframe sandbox>` seguro con postMessage para config; sin acceso al DOM de OpenCCB.
- [x] **OpenCCB Market**: Galería en Studio (`/admin/plugins`) con toggle habilitado/deshabilitado, registro de nuevos plugins y tarjetas con estado visual.
### Fase 36: LTI 1.3 Tool Consumer 🔗
- [ ] **Consumo de herramientas externas**: Capacidad de embeber laboratorios externos (ej: MATLAB, Labster) dentro de OpenCCB.
@@ -118,7 +118,6 @@
**Estado Actual**: Plataforma madura con IA generativa integrada, arquitectura Premium Single-Tenant, búsqueda semántica y monetización operativa.
**Próximas Prioridades**:
1. Finalización de **Certificados y Progreso Real**.
2. Despliegue de **Infraestructura SMTP** para comunicación global.
3. Validación en producción de **Ética de Datos** (panel + endpoint).
4. Endurecimiento de **señales de riesgo IA** (reglas + umbrales + métricas).
1. Implementar **Ecosistema de Plugins** (Fase 35): tabla `course_plugins`, CRUD en Studio, renderizador seguro de Web Components en Experience.
2. Evaluar paso de polling a **WebSocket/SSE** en Pizarras Compartidas una vez validado uso real.
3. Extender el modelo colaborativo a **Edición Multiusuario** de documentos.
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS lesson_collaborative_canvases (
lesson_id UUID PRIMARY KEY REFERENCES lessons(id) ON DELETE CASCADE,
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
canvas_state JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_lesson_collaborative_canvases_org_id
ON lesson_collaborative_canvases (organization_id);
@@ -0,0 +1,2 @@
ALTER TABLE lesson_collaborative_canvases
ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,30 @@
-- Fase 35: Ecosistema de Plugins
-- Tabla de plugins registrados por organización
CREATE TABLE IF NOT EXISTS org_plugins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
component_url TEXT NOT NULL,
icon_url TEXT,
config JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_org_plugins_org_id ON org_plugins(organization_id);
-- Trigger para updated_at automático
CREATE OR REPLACE FUNCTION update_org_plugins_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_org_plugins_updated_at
BEFORE UPDATE ON org_plugins
FOR EACH ROW EXECUTE FUNCTION update_org_plugins_updated_at();
+43 -7
View File
@@ -15,9 +15,12 @@ pub struct BackgroundTask {
pub id: Uuid,
pub title: String,
pub course_title: Option<String>,
pub task_type: String, // 'transcription'
pub task_type: String,
pub status: String,
pub progress: i32,
pub processed_items: i64,
pub failed_items: i64,
pub error_message: Option<String>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
@@ -25,7 +28,8 @@ pub async fn get_background_tasks(
State(pool): State<PgPool>,
) -> Result<Json<Vec<BackgroundTask>>, (StatusCode, String)> {
let query = r#"
SELECT id, title, course_title, task_type, status, progress, updated_at
SELECT id, title, course_title, task_type, status, progress,
processed_items, failed_items, error_message, updated_at
FROM (
SELECT
l.id,
@@ -34,6 +38,9 @@ pub async fn get_background_tasks(
'lesson_transcription' as task_type,
l.transcription_status as status,
0 as progress,
0::bigint as processed_items,
0::bigint as failed_items,
NULL::text as error_message,
l.updated_at
FROM lessons l
JOIN modules m ON l.module_id = m.id
@@ -49,6 +56,9 @@ pub async fn get_background_tasks(
t.task_type,
t.status,
t.progress,
t.processed_items::bigint,
t.failed_items::bigint,
t.error_message,
t.updated_at
FROM background_tasks t
WHERE t.task_type = 'zip_rag_import'
@@ -289,13 +299,39 @@ pub async fn cancel_task(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
// Only cancel transcription in lessons
let _ = sqlx::query(
"UPDATE lessons SET transcription_status = 'idle' WHERE id = $1"
// Try to cancel a transcription lesson first
let lesson_result = sqlx::query(
"UPDATE lessons SET transcription_status = 'idle' WHERE id = $1 AND transcription_status IN ('queued', 'processing')"
)
.bind(id)
.execute(&pool)
.await;
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
if lesson_result.rows_affected() > 0 {
return Ok(StatusCode::NO_CONTENT);
}
// Try to cancel a zip_rag_import background task
let task_result = sqlx::query(
r#"
UPDATE background_tasks
SET status = 'failed',
error_message = 'Cancelado manualmente',
updated_at = NOW()
WHERE id = $1
AND task_type = 'zip_rag_import'
AND status IN ('queued', 'processing')
"#
)
.bind(id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if task_result.rows_affected() > 0 {
return Ok(StatusCode::NO_CONTENT);
}
Ok(StatusCode::NOT_FOUND)
}
@@ -0,0 +1,272 @@
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use common::middleware::Org;
use common::auth::Claims;
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row};
use uuid::Uuid;
// ─────────────────────────────────────────────────────────────────────────────
// Tipos
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct OrgPlugin {
pub id: Uuid,
pub organization_id: Uuid,
pub name: String,
pub description: String,
pub component_url: String,
pub icon_url: Option<String>,
pub config: serde_json::Value,
pub enabled: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePluginPayload {
pub name: String,
pub description: Option<String>,
pub component_url: String,
pub icon_url: Option<String>,
pub config: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct UpdatePluginPayload {
pub name: Option<String>,
pub description: Option<String>,
pub component_url: Option<String>,
pub icon_url: Option<String>,
pub config: Option<serde_json::Value>,
pub enabled: Option<bool>,
}
// ─────────────────────────────────────────────────────────────────────────────
// GET /plugins — listar plugins de la org
// ─────────────────────────────────────────────────────────────────────────────
pub async fn list_plugins(
Org(org_ctx): Org,
State(pool): State<PgPool>,
) -> Result<Json<Vec<OrgPlugin>>, (StatusCode, String)> {
let rows = sqlx::query(
"SELECT id, organization_id, name, description, component_url, icon_url, config, enabled, created_at, updated_at
FROM org_plugins
WHERE organization_id = $1
ORDER BY created_at ASC",
)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let plugins = rows
.into_iter()
.map(|r| OrgPlugin {
id: r.get("id"),
organization_id: r.get("organization_id"),
name: r.get("name"),
description: r.get("description"),
component_url: r.get("component_url"),
icon_url: r.get("icon_url"),
config: r.get("config"),
enabled: r.get("enabled"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
})
.collect();
Ok(Json(plugins))
}
// ─────────────────────────────────────────────────────────────────────────────
// GET /plugins/enabled — solo plugins activos (usado por Experience)
// ─────────────────────────────────────────────────────────────────────────────
pub async fn list_enabled_plugins(
Org(org_ctx): Org,
State(pool): State<PgPool>,
) -> Result<Json<Vec<OrgPlugin>>, (StatusCode, String)> {
let rows = sqlx::query(
"SELECT id, organization_id, name, description, component_url, icon_url, config, enabled, created_at, updated_at
FROM org_plugins
WHERE organization_id = $1 AND enabled = TRUE
ORDER BY created_at ASC",
)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let plugins = rows
.into_iter()
.map(|r| OrgPlugin {
id: r.get("id"),
organization_id: r.get("organization_id"),
name: r.get("name"),
description: r.get("description"),
component_url: r.get("component_url"),
icon_url: r.get("icon_url"),
config: r.get("config"),
enabled: r.get("enabled"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
})
.collect();
Ok(Json(plugins))
}
// ─────────────────────────────────────────────────────────────────────────────
// POST /plugins — crear plugin
// ─────────────────────────────────────────────────────────────────────────────
pub async fn create_plugin(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
Json(payload): Json<CreatePluginPayload>,
) -> Result<(StatusCode, Json<OrgPlugin>), (StatusCode, String)> {
// Validación básica de URL (solo https permitido para componentes externos)
if !payload.component_url.starts_with("https://") {
return Err((
StatusCode::UNPROCESSABLE_ENTITY,
"component_url debe usar HTTPS".to_string(),
));
}
let row = sqlx::query(
r#"
INSERT INTO org_plugins (organization_id, name, description, component_url, icon_url, config)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, organization_id, name, description, component_url, icon_url, config, enabled, created_at, updated_at
"#,
)
.bind(org_ctx.id)
.bind(&payload.name)
.bind(payload.description.as_deref().unwrap_or(""))
.bind(&payload.component_url)
.bind(&payload.icon_url)
.bind(payload.config.unwrap_or(serde_json::json!({})))
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok((StatusCode::CREATED, Json(OrgPlugin {
id: row.get("id"),
organization_id: row.get("organization_id"),
name: row.get("name"),
description: row.get("description"),
component_url: row.get("component_url"),
icon_url: row.get("icon_url"),
config: row.get("config"),
enabled: row.get("enabled"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
})))
}
// ─────────────────────────────────────────────────────────────────────────────
// PUT /plugins/{id} — actualizar plugin
// ─────────────────────────────────────────────────────────────────────────────
pub async fn update_plugin(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
Path(plugin_id): Path<Uuid>,
Json(payload): Json<UpdatePluginPayload>,
) -> Result<Json<OrgPlugin>, (StatusCode, String)> {
// Verificar que pertenece a esta org
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM org_plugins WHERE id = $1 AND organization_id = $2)",
)
.bind(plugin_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !exists {
return Err((StatusCode::NOT_FOUND, "Plugin no encontrado".to_string()));
}
// Validar URL si se actualiza
if let Some(url) = &payload.component_url {
if !url.starts_with("https://") {
return Err((
StatusCode::UNPROCESSABLE_ENTITY,
"component_url debe usar HTTPS".to_string(),
));
}
}
let row = sqlx::query(
r#"
UPDATE org_plugins SET
name = COALESCE($3, name),
description = COALESCE($4, description),
component_url = COALESCE($5, component_url),
icon_url = COALESCE($6, icon_url),
config = COALESCE($7, config),
enabled = COALESCE($8, enabled)
WHERE id = $1 AND organization_id = $2
RETURNING id, organization_id, name, description, component_url, icon_url, config, enabled, created_at, updated_at
"#,
)
.bind(plugin_id)
.bind(org_ctx.id)
.bind(&payload.name)
.bind(&payload.description)
.bind(&payload.component_url)
.bind(&payload.icon_url)
.bind(&payload.config)
.bind(payload.enabled)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(OrgPlugin {
id: row.get("id"),
organization_id: row.get("organization_id"),
name: row.get("name"),
description: row.get("description"),
component_url: row.get("component_url"),
icon_url: row.get("icon_url"),
config: row.get("config"),
enabled: row.get("enabled"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}))
}
// ─────────────────────────────────────────────────────────────────────────────
// DELETE /plugins/{id} — eliminar plugin
// ─────────────────────────────────────────────────────────────────────────────
pub async fn delete_plugin(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
Path(plugin_id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
let result = sqlx::query(
"DELETE FROM org_plugins WHERE id = $1 AND organization_id = $2",
)
.bind(plugin_id)
.bind(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, "Plugin no encontrado".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
+14
View File
@@ -15,6 +15,7 @@ mod handlers_question_bank;
mod handlers_admin;
mod handlers_embeddings;
mod handlers_sam;
mod handlers_plugins;
mod webhooks;
use axum::{
@@ -548,6 +549,19 @@ async fn main() {
"/admin/users/{user_id}/token-limit/check",
get(handlers_admin::check_user_token_limit),
)
// Fase 35: Ecosistema de Plugins
.route(
"/plugins",
get(handlers_plugins::list_plugins).post(handlers_plugins::create_plugin),
)
.route(
"/plugins/enabled",
get(handlers_plugins::list_enabled_plugins),
)
.route(
"/plugins/{id}",
put(handlers_plugins::update_plugin).delete(handlers_plugins::delete_plugin),
)
.route_layer(middleware::from_fn(
common::middleware::org_extractor_middleware,
))
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS lesson_collaborative_canvases (
lesson_id UUID PRIMARY KEY REFERENCES lessons(id) ON DELETE CASCADE,
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
canvas_state JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_lesson_collaborative_canvases_org_id
ON lesson_collaborative_canvases (organization_id);
@@ -0,0 +1,2 @@
ALTER TABLE lesson_collaborative_canvases
ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,15 @@
-- Fase 35: Ecosistema de Plugins (lms-service mirror)
CREATE TABLE IF NOT EXISTS org_plugins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
component_url TEXT NOT NULL,
icon_url TEXT,
config JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_org_plugins_org_id ON org_plugins(organization_id);
+272
View File
@@ -1434,6 +1434,278 @@ pub async fn get_lesson_content(
Ok(Json(lesson))
}
#[derive(Debug, Serialize)]
pub struct CollaborativeCanvasResponse {
pub lesson_id: Uuid,
pub canvas_state: serde_json::Value,
pub revision: i64,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateCollaborativeCanvasPayload {
pub canvas_state: serde_json::Value,
pub expected_revision: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct UpdateCollaborativeCanvasResponse {
pub lesson_id: Uuid,
pub revision: i64,
pub updated_at: DateTime<Utc>,
}
pub async fn get_lesson_collaborative_canvas(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<CollaborativeCanvasResponse>, StatusCode> {
let lesson_exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2)",
)
.bind(id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!(
"get_lesson_collaborative_canvas: failed to validate lesson {} in org {}: {}",
id,
org_ctx.id,
e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
if !lesson_exists {
return Err(StatusCode::NOT_FOUND);
}
#[derive(sqlx::FromRow)]
struct CanvasRow {
canvas_state: serde_json::Value,
revision: i64,
updated_at: DateTime<Utc>,
}
let canvas = sqlx::query_as::<_, CanvasRow>(
r#"
SELECT canvas_state, revision, updated_at
FROM lesson_collaborative_canvases
WHERE lesson_id = $1 AND organization_id = $2
"#,
)
.bind(id)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!(
"get_lesson_collaborative_canvas: failed to fetch canvas for lesson {}: {}",
id,
e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let response = if let Some(canvas) = canvas {
CollaborativeCanvasResponse {
lesson_id: id,
canvas_state: canvas.canvas_state,
revision: canvas.revision,
updated_at: Some(canvas.updated_at),
}
} else {
CollaborativeCanvasResponse {
lesson_id: id,
canvas_state: json!({}),
revision: 0,
updated_at: None,
}
};
Ok(Json(response))
}
pub async fn update_lesson_collaborative_canvas(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateCollaborativeCanvasPayload>,
) -> Result<Json<UpdateCollaborativeCanvasResponse>, (StatusCode, String)> {
let lesson_exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2)",
)
.bind(id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!(
"update_lesson_collaborative_canvas: failed to validate lesson {} in org {}: {}",
id,
org_ctx.id,
e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Error validando lección para canvas colaborativo".to_string(),
)
})?;
if !lesson_exists {
return Err((StatusCode::NOT_FOUND, "Lección no encontrada".to_string()));
}
if let Some(expected_revision) = payload.expected_revision {
#[derive(sqlx::FromRow)]
struct RevisionRow {
revision: i64,
updated_at: DateTime<Utc>,
}
let updated = sqlx::query_as::<_, RevisionRow>(
r#"
UPDATE lesson_collaborative_canvases
SET
canvas_state = $3,
updated_by = $4,
updated_at = NOW(),
revision = revision + 1
WHERE lesson_id = $1
AND organization_id = $2
AND revision = $5
RETURNING revision, updated_at
"#,
)
.bind(id)
.bind(org_ctx.id)
.bind(&payload.canvas_state)
.bind(claims.sub)
.bind(expected_revision)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!(
"update_lesson_collaborative_canvas: optimistic update failed for lesson {}: {}",
id,
e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Error actualizando canvas colaborativo".to_string(),
)
})?;
if let Some(row) = updated {
return Ok(Json(UpdateCollaborativeCanvasResponse {
lesson_id: id,
revision: row.revision,
updated_at: row.updated_at,
}));
}
if expected_revision == 0 {
let inserted = sqlx::query_as::<_, RevisionRow>(
r#"
INSERT INTO lesson_collaborative_canvases (
lesson_id,
organization_id,
canvas_state,
updated_by,
revision,
updated_at
)
VALUES ($1, $2, $3, $4, 1, NOW())
ON CONFLICT (lesson_id) DO NOTHING
RETURNING revision, updated_at
"#,
)
.bind(id)
.bind(org_ctx.id)
.bind(&payload.canvas_state)
.bind(claims.sub)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!(
"update_lesson_collaborative_canvas: insert-on-zero failed for lesson {}: {}",
id,
e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Error creando canvas colaborativo".to_string(),
)
})?;
if let Some(row) = inserted {
return Ok(Json(UpdateCollaborativeCanvasResponse {
lesson_id: id,
revision: row.revision,
updated_at: row.updated_at,
}));
}
}
return Err((
StatusCode::CONFLICT,
"Conflicto de edición: el canvas fue actualizado por otro usuario. Recarga y vuelve a intentar.".to_string(),
));
}
#[derive(sqlx::FromRow)]
struct RevisionRow {
revision: i64,
updated_at: DateTime<Utc>,
}
let row = sqlx::query_as::<_, RevisionRow>(
r#"
INSERT INTO lesson_collaborative_canvases (
lesson_id,
organization_id,
canvas_state,
updated_by,
revision,
updated_at
)
VALUES ($1, $2, $3, $4, 1, NOW())
ON CONFLICT (lesson_id)
DO UPDATE SET
canvas_state = EXCLUDED.canvas_state,
updated_by = EXCLUDED.updated_by,
updated_at = NOW(),
revision = lesson_collaborative_canvases.revision + 1
RETURNING revision, updated_at
"#,
)
.bind(id)
.bind(org_ctx.id)
.bind(&payload.canvas_state)
.bind(claims.sub)
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!(
"update_lesson_collaborative_canvas: failed to upsert canvas for lesson {}: {}",
id,
e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Error guardando canvas colaborativo".to_string(),
)
})?;
Ok(Json(UpdateCollaborativeCanvasResponse {
lesson_id: id,
revision: row.revision,
updated_at: row.updated_at,
}))
}
pub async fn get_user_enrollments(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -0,0 +1,435 @@
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use common::middleware::Org;
use serde::Serialize;
use sqlx::{PgPool, Row};
use uuid::Uuid;
// ─────────────────────────────────────────────────────────────────────────────
// Structs de respuesta
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct LessonQualityMetric {
pub lesson_id: Uuid,
pub lesson_title: String,
pub position: i32,
/// % de inscritos que entregaron al menos una vez
pub completion_rate: f64,
/// Promedio de intentos por alumno que entregó
pub avg_attempts: f64,
/// Promedio de puntaje (01)
pub avg_score: f64,
/// % de alumnos que reprobaron en todos sus intentos (score < 0.6)
pub failure_rate: f64,
/// Número de alumnos que nunca interactuaron con la lección
pub abandonment_count: i64,
}
#[derive(Debug, Serialize)]
pub struct CourseQualityMetrics {
pub course_id: Uuid,
pub enrolled: i64,
pub lessons: Vec<LessonQualityMetric>,
}
#[derive(Debug, Serialize)]
pub struct QuizDiscriminationItem {
pub lesson_id: Uuid,
pub lesson_title: String,
/// Texto truncado de la pregunta (block_id como proxy)
pub block_id: String,
/// Correlación punto-biserial approx: diferencia de score medio
/// entre alumnos que respondieron bien vs mal esa pregunta
pub discrimination_index: f64,
/// % de alumnos que acertaron esta pregunta
pub facility_index: f64,
pub sample_size: i64,
}
#[derive(Debug, Serialize)]
pub struct CourseDiscriminationReport {
pub course_id: Uuid,
pub items: Vec<QuizDiscriminationItem>,
}
#[derive(Debug, Serialize)]
pub struct CurricularSuggestion {
pub lesson_id: Uuid,
pub lesson_title: String,
pub kind: &'static str,
pub message: String,
pub severity: &'static str,
}
#[derive(Debug, Serialize)]
pub struct CurricularSuggestionsReport {
pub course_id: Uuid,
pub suggestions: Vec<CurricularSuggestion>,
}
// ─────────────────────────────────────────────────────────────────────────────
// Endpoint 1: Métricas de Calidad
// GET /courses/{id}/pedagogical/quality-metrics
// ─────────────────────────────────────────────────────────────────────────────
pub async fn get_lesson_quality_metrics(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<CourseQualityMetrics>, (StatusCode, String)> {
let enrolled: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM enrollments WHERE course_id = $1 AND organization_id = $2",
)
.bind(course_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if enrolled == 0 {
return Ok(Json(CourseQualityMetrics {
course_id,
enrolled: 0,
lessons: vec![],
}));
}
let rows = sqlx::query(
r#"
SELECT
l.id AS lesson_id,
l.title AS lesson_title,
l.position,
-- Alumnos que entregaron al menos una vez
COUNT(DISTINCT g.user_id)::float8 / NULLIF($3::float8, 0) AS completion_rate,
-- Promedio de intentos entre quienes entregaron
COALESCE(AVG(g.attempts_count)::float8, 0) AS avg_attempts,
-- Puntaje medio
COALESCE(AVG(g.score)::float8, 0) AS avg_score,
-- Tasa de fallo (todos los intentos con score < 0.6)
(
SELECT COALESCE(
COUNT(DISTINCT g2.user_id)::float8 / NULLIF(COUNT(DISTINCT g.user_id)::float8, 0),
0
)
FROM user_grades g2
WHERE g2.lesson_id = l.id
AND g2.organization_id = $2
AND g2.score < 0.6
) AS failure_rate,
-- Alumnos inscritos que NUNCA enviaron esta lección
(
$3 - COUNT(DISTINCT g.user_id)
) AS abandonment_count
FROM lessons l
JOIN modules m ON l.module_id = m.id
LEFT JOIN user_grades g
ON g.lesson_id = l.id AND g.organization_id = $2
WHERE m.course_id = $1
AND l.organization_id = $2
GROUP BY l.id, l.title, l.position
ORDER BY l.position
"#,
)
.bind(course_id)
.bind(org_ctx.id)
.bind(enrolled)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let lessons = rows
.into_iter()
.map(|row| LessonQualityMetric {
lesson_id: row.get("lesson_id"),
lesson_title: row.get("lesson_title"),
position: row.get("position"),
completion_rate: row.get::<f64, _>("completion_rate"),
avg_attempts: row.get::<f64, _>("avg_attempts"),
avg_score: row.get::<f64, _>("avg_score"),
failure_rate: row.get::<f64, _>("failure_rate"),
abandonment_count: row.get::<i64, _>("abandonment_count"),
})
.collect();
Ok(Json(CourseQualityMetrics {
course_id,
enrolled,
lessons,
}))
}
// ─────────────────────────────────────────────────────────────────────────────
// Endpoint 2: Índice de Discriminación de preguntas
// GET /courses/{id}/pedagogical/discrimination-index
//
// Usa punto-biserial simplificado: compara el puntaje global del curso entre
// alumnos que acertaron cada bloque vs los que fallaron.
// La columna metadata->>'block_scores' guarda un objeto {block_id: score_0_1}.
// ─────────────────────────────────────────────────────────────────────────────
pub async fn get_quiz_discrimination_index(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<CourseDiscriminationReport>, (StatusCode, String)> {
use std::collections::HashMap;
let rows = sqlx::query(
r#"
SELECT
g.lesson_id,
l.title AS lesson_title,
bs.key AS block_id,
(bs.value::text::float8) AS block_score,
g.score AS overall_score
FROM user_grades g
JOIN lessons l ON l.id = g.lesson_id
JOIN LATERAL jsonb_each(
CASE
WHEN g.metadata ? 'block_scores'
THEN g.metadata -> 'block_scores'
ELSE '{}'::jsonb
END
) AS bs(key, value) ON TRUE
WHERE g.course_id = $1
AND g.organization_id = $2
AND (bs.value::text) ~ '^[0-9]+\.?[0-9]*$'
"#,
)
.bind(course_id)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if rows.is_empty() {
return Ok(Json(CourseDiscriminationReport {
course_id,
items: vec![],
}));
}
struct BlockStats {
lesson_id: Uuid,
lesson_title: String,
block_id: String,
pass_scores: Vec<f64>,
fail_scores: Vec<f64>,
pass_count: i64,
total: i64,
}
let mut map: HashMap<String, BlockStats> = HashMap::new();
for row in &rows {
let lesson_id: Uuid = row.get("lesson_id");
let lesson_title: String = row.get("lesson_title");
let block_id: String = row.get("block_id");
let block_score: f64 = row.get("block_score");
let overall_score: f64 = row.get("overall_score");
let key = format!("{}/{}", lesson_id, block_id);
let entry = map.entry(key).or_insert_with(|| BlockStats {
lesson_id,
lesson_title: lesson_title.clone(),
block_id: block_id.clone(),
pass_scores: vec![],
fail_scores: vec![],
pass_count: 0,
total: 0,
});
entry.total += 1;
if block_score >= 0.6 {
entry.pass_count += 1;
entry.pass_scores.push(overall_score);
} else {
entry.fail_scores.push(overall_score);
}
}
let mean = |v: &[f64]| -> f64 {
if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 }
};
let mut items: Vec<QuizDiscriminationItem> = map
.into_values()
.filter(|s| s.total >= 3)
.map(|s| {
let facility = s.pass_count as f64 / s.total as f64;
let discrimination = mean(&s.pass_scores) - mean(&s.fail_scores);
QuizDiscriminationItem {
lesson_id: s.lesson_id,
lesson_title: s.lesson_title,
block_id: s.block_id,
discrimination_index: (discrimination * 100.0).round() / 100.0,
facility_index: (facility * 100.0).round() / 100.0,
sample_size: s.total,
}
})
.collect();
items.sort_by(|a, b| a.discrimination_index.partial_cmp(&b.discrimination_index).unwrap_or(std::cmp::Ordering::Equal));
Ok(Json(CourseDiscriminationReport { course_id, items }))
}
// ─────────────────────────────────────────────────────────────────────────────
// Endpoint 3: Sugerencias Curriculares con reglas basadas en datos
// GET /courses/{id}/pedagogical/suggestions
// ─────────────────────────────────────────────────────────────────────────────
pub async fn get_curricular_suggestions(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<CurricularSuggestionsReport>, (StatusCode, String)> {
let enrolled: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM enrollments WHERE course_id = $1 AND organization_id = $2",
)
.bind(course_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if enrolled < 5 {
return Ok(Json(CurricularSuggestionsReport {
course_id,
suggestions: vec![],
}));
}
let rows = sqlx::query(
r#"
SELECT
l.id AS lesson_id,
l.title AS lesson_title,
l.position,
COUNT(DISTINCT g.user_id)::float8 / $3::float8 AS completion_rate,
COALESCE(AVG(g.score)::float8, 0) AS avg_score,
COALESCE(AVG(g.attempts_count)::float8, 0) AS avg_attempts,
-- Tasa de abandono
($3 - COUNT(DISTINCT g.user_id))::float8 / $3::float8 AS abandonment_rate
FROM lessons l
JOIN modules m ON l.module_id = m.id
LEFT JOIN user_grades g
ON g.lesson_id = l.id AND g.organization_id = $2
WHERE m.course_id = $1
AND l.organization_id = $2
GROUP BY l.id, l.title, l.position
ORDER BY l.position
"#,
)
.bind(course_id)
.bind(org_ctx.id)
.bind(enrolled)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut suggestions: Vec<CurricularSuggestion> = vec![];
for row in &rows {
let lesson_id: Uuid = row.get("lesson_id");
let lesson_title: String = row.get("lesson_title");
let completion_rate: f64 = row.get("completion_rate");
let avg_score: f64 = row.get("avg_score");
let avg_attempts: f64 = row.get("avg_attempts");
let abandonment_rate: f64 = row.get("abandonment_rate");
// Regla 1: Alto abandono sin completar
if abandonment_rate > 0.50 {
suggestions.push(CurricularSuggestion {
lesson_id,
lesson_title: lesson_title.clone(),
kind: "high_abandonment",
message: format!(
"{:.0}% de los alumnos no completan esta lección. Considera dividirla en partes más cortas o revisar si el contenido es demasiado extenso.",
abandonment_rate * 100.0
),
severity: "high",
});
}
// Regla 2: Puntaje promedio muy bajo con muchos intentos → dificultad excesiva
if avg_score < 0.45 && avg_attempts > 2.0 && completion_rate > 0.3 {
suggestions.push(CurricularSuggestion {
lesson_id,
lesson_title: lesson_title.clone(),
kind: "excessive_difficulty",
message: format!(
"Puntaje promedio de {:.0}% tras {:.1} intentos. La lección puede ser excesivamente difícil; revisa las preguntas o añade material de apoyo previo.",
avg_score * 100.0,
avg_attempts
),
severity: "high",
});
}
// Regla 3: Puntaje muy alto con pocos intentos → podría ser demasiado fácil
if avg_score > 0.95 && avg_attempts < 1.2 && completion_rate > 0.5 {
suggestions.push(CurricularSuggestion {
lesson_id,
lesson_title: lesson_title.clone(),
kind: "too_easy",
message: format!(
"Puntaje promedio de {:.0}% en el primer intento. Esta lección puede no estar generando aprendizaje real; considera aumentar la complejidad o añadir preguntas de análisis.",
avg_score * 100.0
),
severity: "info",
});
}
// Regla 4: Baja tasa de finalización en lección no final → posible bloqueo
if completion_rate < 0.30 && abandonment_rate < 0.50 {
suggestions.push(CurricularSuggestion {
lesson_id,
lesson_title: lesson_title.clone(),
kind: "low_completion",
message: format!(
"Solo {:.0}% de alumnos completa esta lección. Si no hay abandonos, puede haber un bloqueo técnico o de prerrequisitos. Verifica dependencias y contenido de la lección.",
completion_rate * 100.0
),
severity: "medium",
});
}
// Regla 5: Muchos reintentos con buen puntaje final → la práctica funciona, destacar
if avg_attempts > 3.0 && avg_score > 0.75 {
suggestions.push(CurricularSuggestion {
lesson_id,
lesson_title: lesson_title.clone(),
kind: "effective_practice",
message: format!(
"Alumnos usan en promedio {:.1} intentos y llegan a {:.0}%. Esta lección fomenta el aprendizaje por práctica de forma efectiva.",
avg_attempts,
avg_score * 100.0
),
severity: "positive",
});
}
}
suggestions.sort_by_key(|s| match s.severity {
"high" => 0,
"medium" => 1,
"info" => 2,
"positive" => 3,
_ => 4,
});
Ok(Json(CurricularSuggestionsReport {
course_id,
suggestions,
}))
}
+19
View File
@@ -1,6 +1,7 @@
mod db_util;
mod handlers;
mod handlers_announcements;
mod handlers_pedagogical;
mod handlers_email;
mod handlers_scorm;
mod handlers_search;
@@ -161,6 +162,11 @@ async fn main() {
.route("/courses/{id}/outline", get(handlers::get_course_outline))
.route("/courses/{id}/progress-stats", get(handlers::get_student_progress_stats))
.route("/lessons/{id}", get(handlers::get_lesson_content))
.route(
"/lessons/{id}/collaborative-canvas",
get(handlers::get_lesson_collaborative_canvas)
.put(handlers::update_lesson_collaborative_canvas),
)
.route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark))
.route("/bookmarks", get(handlers::get_user_bookmarks))
.route("/grades", post(handlers::submit_lesson_score))
@@ -258,6 +264,19 @@ async fn main() {
"/ai/data-ethics/summary",
get(handlers_data_ethics::get_data_ethics_summary),
)
// Análisis Pedagógico Profundo (Fase 34)
.route(
"/courses/{id}/pedagogical/quality-metrics",
get(handlers_pedagogical::get_lesson_quality_metrics),
)
.route(
"/courses/{id}/pedagogical/discrimination-index",
get(handlers_pedagogical::get_quiz_discrimination_index),
)
.route(
"/courses/{id}/pedagogical/suggestions",
get(handlers_pedagogical::get_curricular_suggestions),
)
// Moderación humana para FAQ basada en chats de alumnos
.route(
"/faq/review/import-candidates",
@@ -22,10 +22,12 @@ import RolePlayingPlayer from "@/components/blocks/RolePlayingPlayer";
import PeerReviewPlayer from "@/components/blocks/PeerReviewPlayer";
import MermaidViewer from "@/components/blocks/MermaidViewer";
import ScormPlayer from "@/components/blocks/ScormPlayer";
import PluginBlock from "@/components/blocks/PluginBlock";
import InteractiveTranscript from "@/components/InteractiveTranscript";
import AITutor from "@/components/AITutor";
import LessonLockedView from "@/components/LessonLockedView";
import StudentNotes from "@/components/StudentNotes";
import CollaborativeWhiteboard from "@/components/CollaborativeWhiteboard";
import { ListMusic, StickyNote } from "lucide-react";
import ReactMarkdown from "react-markdown";
export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) {
@@ -522,6 +524,15 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
);
case 'mermaid':
return <MermaidViewer block={block} />;
case 'plugin':
return (
<PluginBlock
pluginId={block.id}
name={block.title || 'Plugin'}
componentUrl={block.component_url || ''}
config={block.config as Record<string, unknown> | undefined}
/>
);
case 'scorm':
return (
<ScormPlayer
@@ -608,6 +619,10 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
)}
</div>
)}
<div className="pt-12 border-t border-black/5 dark:border-white/5 animate-in fade-in slide-in-from-bottom-8 duration-1000">
<CollaborativeWhiteboard lessonId={params.lessonId} />
</div>
</article>
</div>
@@ -0,0 +1,388 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
import { lmsApi, CollaborativeCanvasState } from "@/lib/api";
import { AlertTriangle, CheckCircle, Loader2, RefreshCw, Save, Trash2 } from "lucide-react";
type ConflictInfo = {
localStrokes: Stroke[];
remoteStrokes: Stroke[];
remoteRevision: number;
remoteUpdatedAt: string;
};
type Point = { x: number; y: number };
type Stroke = {
points: Point[];
color: string;
width: number;
};
type Props = {
lessonId: string;
};
const DEFAULT_CANVAS: CollaborativeCanvasState = { strokes: [] };
function toStrokeArray(state: CollaborativeCanvasState): Stroke[] {
if (!Array.isArray(state.strokes)) {
return [];
}
return state.strokes
.map((stroke) => {
const points = Array.isArray(stroke.points)
? stroke.points
.filter((p): p is Point => typeof p?.x === "number" && typeof p?.y === "number")
.map((p) => ({ x: p.x, y: p.y }))
: [];
return {
points,
color: typeof stroke.color === "string" ? stroke.color : "#1f2937",
width: typeof stroke.width === "number" ? stroke.width : 2,
};
})
.filter((stroke) => stroke.points.length > 1);
}
export default function CollaborativeWhiteboard({ lessonId }: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const [strokes, setStrokes] = useState<Stroke[]>([]);
const [draftStroke, setDraftStroke] = useState<Stroke | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [lastSavedAt, setLastSavedAt] = useState<string | null>(null);
const [revision, setRevision] = useState(0);
const [error, setError] = useState<string | null>(null);
const [conflict, setConflict] = useState<ConflictInfo | null>(null);
const isDrawing = useRef(false);
const allStrokes = useMemo(() => {
return draftStroke ? [...strokes, draftStroke] : strokes;
}, [strokes, draftStroke]);
const resizeCanvas = useCallback(() => {
const canvas = canvasRef.current;
const wrapper = wrapperRef.current;
if (!canvas || !wrapper) return;
const rect = wrapper.getBoundingClientRect();
const scale = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(rect.width * scale));
canvas.height = Math.max(1, Math.floor(340 * scale));
canvas.style.width = `${rect.width}px`;
canvas.style.height = "340px";
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.scale(scale, scale);
}, []);
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (const stroke of allStrokes) {
if (stroke.points.length < 2) continue;
ctx.beginPath();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width;
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
for (let i = 1; i < stroke.points.length; i += 1) {
ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
}
ctx.stroke();
}
}, [allStrokes]);
useEffect(() => {
resizeCanvas();
draw();
}, [resizeCanvas, draw]);
useEffect(() => {
const onResize = () => {
resizeCanvas();
draw();
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [draw, resizeCanvas]);
const getPoint = (event: PointerEvent<HTMLCanvasElement>): Point | null => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
return { x, y };
};
const loadCanvas = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await lmsApi.getLessonCollaborativeCanvas(lessonId);
const loadedStrokes = toStrokeArray(data.canvas_state || DEFAULT_CANVAS);
setStrokes(loadedStrokes);
setDraftStroke(null);
setLastSavedAt(data.updated_at || null);
setRevision(data.revision || 0);
setDirty(false);
} catch (e) {
console.error("Error loading collaborative canvas", e);
setError("No se pudo cargar la pizarra colaborativa.");
} finally {
setLoading(false);
}
}, [lessonId]);
const saveCanvas = useCallback(async (force = false) => {
if (saving || loading || isDrawing.current) return;
setSaving(true);
setError(null);
const expectedRev = force ? undefined : revision;
try {
const result = await lmsApi.updateLessonCollaborativeCanvas(lessonId, { strokes }, expectedRev);
setLastSavedAt(result.updated_at);
setRevision(result.revision);
setDirty(false);
setConflict(null);
} catch (e) {
console.error("Error saving collaborative canvas", e);
if (e instanceof Error && e.message.toLowerCase().includes("conflicto")) {
// Fetch remote state to show diff panel
try {
const remote = await lmsApi.getLessonCollaborativeCanvas(lessonId);
setConflict({
localStrokes: strokes,
remoteStrokes: toStrokeArray(remote.canvas_state || DEFAULT_CANVAS),
remoteRevision: remote.revision || 0,
remoteUpdatedAt: remote.updated_at ?? new Date().toISOString(),
});
} catch {
setError("Conflicto detectado y no se pudo obtener el estado remoto. Recarga manualmente.");
}
return;
}
setError("No se pudo guardar la pizarra. Intenta nuevamente.");
} finally {
setSaving(false);
}
}, [lessonId, loading, revision, saving, strokes]);
useEffect(() => {
loadCanvas();
}, [loadCanvas]);
const acceptRemote = useCallback(() => {
if (!conflict) return;
setStrokes(conflict.remoteStrokes);
setRevision(conflict.remoteRevision);
setLastSavedAt(conflict.remoteUpdatedAt);
setDirty(false);
setConflict(null);
setError(null);
}, [conflict]);
const forceLocal = useCallback(() => {
setConflict(null);
void saveCanvas(true);
}, [saveCanvas]);
useEffect(() => {
if (!dirty || loading || saving || isDrawing.current) {
return;
}
const timeoutId = setTimeout(() => {
void saveCanvas();
}, 1500);
return () => clearTimeout(timeoutId);
}, [dirty, loading, saveCanvas, saving, strokes]);
useEffect(() => {
const interval = setInterval(async () => {
if (dirty || isDrawing.current) return;
try {
const data = await lmsApi.getLessonCollaborativeCanvas(lessonId);
const serverStamp = data.updated_at || null;
if (serverStamp !== lastSavedAt) {
setStrokes(toStrokeArray(data.canvas_state || DEFAULT_CANVAS));
setLastSavedAt(serverStamp);
setRevision(data.revision || 0);
}
} catch (e) {
console.error("Polling collaborative canvas failed", e);
}
}, 5000);
return () => clearInterval(interval);
}, [dirty, lastSavedAt, lessonId]);
return (
<section className="space-y-4 rounded-3xl border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">Pizarra colaborativa (MVP)</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">Dibuja ideas rápidas de la lección y compártelas con tu grupo.</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setStrokes([]);
setDraftStroke(null);
setDirty(true);
}}
className="inline-flex items-center gap-1 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100"
>
<Trash2 className="h-3 w-3" /> Limpiar
</button>
<button
type="button"
onClick={() => void saveCanvas()}
disabled={saving || (!dirty && !loading)}
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 hover:bg-blue-100 disabled:opacity-50"
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />} Guardar
</button>
<button
type="button"
onClick={loadCanvas}
disabled={loading}
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-100 disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} /> Recargar
</button>
</div>
</div>
{error && <p className="text-xs font-semibold text-red-600">{error}</p>}
{conflict && (
<div className="rounded-2xl border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
<div>
<p className="text-xs font-black text-amber-800 dark:text-amber-300">
Conflicto de edición detectado
</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
Otro usuario guardó mientras editabas. Elige qué versión conservar:
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-[11px]">
<div className="rounded-xl border border-blue-200 bg-blue-50 dark:bg-blue-900/20 p-3">
<p className="font-black text-blue-800 dark:text-blue-300 mb-1">Tu versión (local)</p>
<p className="text-blue-700 dark:text-blue-400">
{conflict.localStrokes.length} trazos
</p>
<p className="text-blue-500 dark:text-blue-500 mt-1">No guardada</p>
</div>
<div className="rounded-xl border border-green-200 bg-green-50 dark:bg-green-900/20 p-3">
<p className="font-black text-green-800 dark:text-green-300 mb-1">Versión del servidor</p>
<p className="text-green-700 dark:text-green-400">
{conflict.remoteStrokes.length} trazos · rev. {conflict.remoteRevision}
</p>
<p className="text-green-500 dark:text-green-500 mt-1">
{new Date(conflict.remoteUpdatedAt).toLocaleTimeString()}
</p>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={forceLocal}
disabled={saving}
className="inline-flex items-center gap-1 rounded-lg border border-blue-300 bg-blue-100 px-3 py-1.5 text-xs font-semibold text-blue-800 hover:bg-blue-200 disabled:opacity-50"
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />}
Guardar mi versión
</button>
<button
type="button"
onClick={acceptRemote}
className="inline-flex items-center gap-1 rounded-lg border border-green-300 bg-green-100 px-3 py-1.5 text-xs font-semibold text-green-800 hover:bg-green-200"
>
<CheckCircle className="h-3 w-3" />
Usar versión del servidor
</button>
</div>
</div>
)}
<div ref={wrapperRef} className="w-full overflow-hidden rounded-2xl border border-black/10 bg-white">
<canvas
ref={canvasRef}
className="touch-none"
onPointerDown={(event) => {
const point = getPoint(event);
if (!point) return;
isDrawing.current = true;
setDraftStroke({ points: [point], color: "#1f2937", width: 2 });
}}
onPointerMove={(event) => {
if (!isDrawing.current) return;
const point = getPoint(event);
if (!point) return;
setDraftStroke((prev) => {
if (!prev) return prev;
return { ...prev, points: [...prev.points, point] };
});
}}
onPointerUp={() => {
if (!isDrawing.current) return;
isDrawing.current = false;
setDraftStroke((prev) => {
if (!prev || prev.points.length < 2) return null;
setStrokes((current) => [...current, prev]);
setDirty(true);
return null;
});
}}
onPointerLeave={() => {
if (!isDrawing.current) return;
isDrawing.current = false;
setDraftStroke((prev) => {
if (!prev || prev.points.length < 2) return null;
setStrokes((current) => [...current, prev]);
setDirty(true);
return null;
});
}}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] font-medium text-gray-500 dark:text-gray-400">
<span>Trazos: {strokes.length}</span>
<span>
{saving
? "Guardando..."
: dirty
? "Cambios sin guardar (autosave en 1.5s)"
: "Sin cambios pendientes"}
</span>
<span>Revision: {revision}</span>
<span>Última sincronización: {lastSavedAt ? new Date(lastSavedAt).toLocaleTimeString() : "nunca"}</span>
</div>
</section>
);
}
@@ -0,0 +1,103 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { Puzzle, AlertTriangle, ExternalLink } from "lucide-react";
interface PluginBlockProps {
pluginId: string;
name: string;
componentUrl: string;
config?: Record<string, unknown>;
}
/**
* Renderiza un Web Component externo dentro de un iframe sandboxed.
* El sandbox permite scripts y same-origin pero bloquea navegación superior,
* formularios externos y acceso a cámara/micrófono sin permiso explícito.
*/
export default function PluginBlock({ pluginId, name, componentUrl, config = {} }: PluginBlockProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [error, setError] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
// Solo permitir HTTPS
const isSecure = componentUrl.startsWith("https://");
useEffect(() => {
if (!isSecure) {
setError("Este plugin no puede cargarse: la URL debe usar HTTPS.");
return;
}
// Enviar config al iframe cuando cargue vía postMessage
const handleLoad = () => {
setLoaded(true);
iframeRef.current?.contentWindow?.postMessage(
{ type: "OPENCCB_PLUGIN_CONFIG", pluginId, config },
new URL(componentUrl).origin
);
};
const iframe = iframeRef.current;
if (iframe) {
iframe.addEventListener("load", handleLoad);
return () => iframe.removeEventListener("load", handleLoad);
}
}, [componentUrl, config, isSecure, pluginId]);
if (!isSecure) {
return (
<div className="flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-700 dark:text-red-300">
<AlertTriangle className="w-5 h-5 shrink-0" />
<span>El plugin <strong>{name}</strong> no puede cargarse: URL no segura.</span>
</div>
);
}
return (
<div className="rounded-2xl border border-black/10 dark:border-white/10 overflow-hidden bg-white dark:bg-black/20">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-black/5 dark:border-white/5 bg-black/2 dark:bg-white/3">
<div className="flex items-center gap-2 text-sm font-medium">
<Puzzle className="w-4 h-4 text-indigo-500" />
{name}
</div>
<a
href={componentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-black/30 dark:text-white/30 hover:text-black/60 dark:hover:text-white/60 flex items-center gap-1 transition-colors"
>
<ExternalLink className="w-3 h-3" />
Abrir
</a>
</div>
{/* Loading state */}
{!loaded && (
<div className="flex items-center justify-center h-48 text-black/30 dark:text-white/30 text-sm animate-pulse">
Cargando plugin
</div>
)}
{/* Iframe sandboxed */}
<iframe
ref={iframeRef}
src={componentUrl}
title={name}
className={`w-full transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0 h-0"}`}
style={{ minHeight: loaded ? "400px" : "0px", border: "none" }}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
loading="lazy"
onError={() => setError("No se pudo cargar el plugin.")}
/>
{error && (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20">
<AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
</div>
);
}
+62 -2
View File
@@ -184,7 +184,7 @@ export interface QuizQuestion {
export interface Block {
id: string;
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab' | 'scorm';
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab' | 'scorm' | 'plugin';
title: string;
content?: string;
url?: string;
@@ -228,6 +228,21 @@ export interface Block {
// SCORM/xAPI fields
launch_url?: string;
metadata?: any;
// Plugin fields
component_url?: string;
}
export interface OrgPlugin {
id: string;
organization_id: string;
name: string;
description: string;
component_url: string;
icon_url: string | null;
config: Record<string, unknown>;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface TrackXapiPayload {
@@ -295,6 +310,28 @@ export interface LessonDependency {
created_at: string;
}
export interface CollaborativeCanvasState {
strokes?: Array<{
points: Array<{ x: number; y: number }>;
color?: string;
width?: number;
}>;
[key: string]: unknown;
}
export interface CollaborativeCanvas {
lesson_id: string;
canvas_state: CollaborativeCanvasState;
revision: number;
updated_at?: string | null;
}
export interface CollaborativeCanvasUpdateResult {
lesson_id: string;
revision: number;
updated_at: string;
}
export interface UserGrade {
id: string;
user_id: string;
@@ -846,6 +883,24 @@ export const lmsApi = {
return apiFetch(`/lessons/${id}`);
},
async getLessonCollaborativeCanvas(lessonId: string): Promise<CollaborativeCanvas> {
return apiFetch(`/lessons/${lessonId}/collaborative-canvas`);
},
async updateLessonCollaborativeCanvas(
lessonId: string,
canvasState: CollaborativeCanvasState,
expectedRevision?: number
): Promise<CollaborativeCanvasUpdateResult> {
return apiFetch(`/lessons/${lessonId}/collaborative-canvas`, {
method: 'PUT',
body: JSON.stringify({
canvas_state: canvasState,
expected_revision: expectedRevision,
})
});
},
async register(payload: AuthPayload): Promise<AuthResponse> {
return apiFetch('/auth/register', {
method: 'POST',
@@ -1278,5 +1333,10 @@ export const lmsApi = {
},
async verifyCertificate(code: string): Promise<any> {
return apiFetch(`/certificates/verify/${code}`);
}
},
// Fase 35: Plugins
getEnabledPlugins(): Promise<OrgPlugin[]> {
return apiFetch('/plugins/enabled', {}, true);
},
};
+379
View File
@@ -0,0 +1,379 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { lmsApi, OrgPlugin, CreatePluginPayload } from "@/lib/api";
import {
Puzzle,
Plus,
Trash2,
ToggleLeft,
ToggleRight,
ExternalLink,
RefreshCw,
AlertTriangle,
CheckCircle2,
X,
} from "lucide-react";
// ─────────────────────────────────────────────────────────────────────────────
// Modal: Crear plugin
// ─────────────────────────────────────────────────────────────────────────────
function CreatePluginModal({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (p: OrgPlugin) => void;
}) {
const [form, setForm] = useState<CreatePluginPayload>({
name: "",
description: "",
component_url: "",
icon_url: "",
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.component_url.startsWith("https://")) {
setError("La URL del componente debe comenzar con https://");
return;
}
setSaving(true);
setError(null);
try {
const plugin = await lmsApi.createPlugin({
name: form.name,
description: form.description || undefined,
component_url: form.component_url,
icon_url: form.icon_url || undefined,
});
onCreated(plugin);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Error al crear plugin");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl w-full max-w-lg border border-black/10 dark:border-white/10">
<div className="flex items-center justify-between p-6 border-b border-black/5 dark:border-white/5">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Puzzle className="w-5 h-5 text-indigo-500" />
Registrar Plugin
</h2>
<button onClick={onClose} className="text-black/40 dark:text-white/40 hover:text-black dark:hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={(e) => void handleSubmit(e)} className="p-6 space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium">Nombre *</label>
<input
required
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="Mi Plugin Interactivo"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Descripción</label>
<input
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"
value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
placeholder="Descripción breve del plugin"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">URL del Web Component *</label>
<input
required
type="url"
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 font-mono"
value={form.component_url}
onChange={e => setForm(f => ({ ...f, component_url: e.target.value }))}
placeholder="https://mi-plugin.ejemplo.com/component"
/>
<p className="text-xs text-black/40 dark:text-white/40">Solo se permiten URLs HTTPS. El componente se cargará en un iframe sandboxed.</p>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">URL del Icono</label>
<input
type="url"
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 font-mono"
value={form.icon_url}
onChange={e => setForm(f => ({ ...f, icon_url: e.target.value }))}
placeholder="https://…/icon.svg (opcional)"
/>
</div>
{error && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-sm text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800">
<AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg text-sm text-black/60 dark:text-white/60 hover:bg-black/5 dark:hover:bg-white/5 transition-colors">
Cancelar
</button>
<button
type="submit"
disabled={saving}
className="px-4 py-2 rounded-lg text-sm bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors flex items-center gap-2"
>
{saving && <RefreshCw className="w-3 h-3 animate-spin" />}
Registrar
</button>
</div>
</form>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Tarjeta de Plugin
// ─────────────────────────────────────────────────────────────────────────────
function PluginCard({
plugin,
onToggle,
onDelete,
}: {
plugin: OrgPlugin;
onToggle: (id: string, enabled: boolean) => void;
onDelete: (id: string) => void;
}) {
const [deleting, setDeleting] = useState(false);
const [toggling, setToggling] = useState(false);
const handleToggle = async () => {
setToggling(true);
await onToggle(plugin.id, !plugin.enabled);
setToggling(false);
};
const handleDelete = async () => {
if (!confirm(`¿Eliminar el plugin "${plugin.name}"? Esta acción no se puede deshacer.`)) return;
setDeleting(true);
await onDelete(plugin.id);
setDeleting(false);
};
return (
<div className={`flex items-start gap-4 p-4 rounded-xl border transition-all ${plugin.enabled ? "border-black/10 dark:border-white/10 bg-white/60 dark:bg-white/5" : "border-black/5 dark:border-white/5 bg-black/2 dark:bg-white/2 opacity-60"}`}>
{/* Icono */}
<div className="w-10 h-10 rounded-lg bg-indigo-100 dark:bg-indigo-900/30 flex items-center justify-center shrink-0">
{plugin.icon_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={plugin.icon_url} alt="" className="w-6 h-6 object-contain" />
) : (
<Puzzle className="w-5 h-5 text-indigo-500" />
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{plugin.name}</span>
{plugin.enabled ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">Activo</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full bg-black/5 dark:bg-white/5 text-black/40 dark:text-white/40">Inactivo</span>
)}
</div>
{plugin.description && (
<p className="text-xs text-black/50 dark:text-white/50 mt-0.5">{plugin.description}</p>
)}
<a
href={plugin.component_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-indigo-500 hover:text-indigo-700 dark:hover:text-indigo-300 mt-1 transition-colors font-mono truncate max-w-xs"
>
<ExternalLink className="w-3 h-3 shrink-0" />
{plugin.component_url}
</a>
</div>
{/* Acciones */}
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => void handleToggle()}
disabled={toggling}
title={plugin.enabled ? "Deshabilitar" : "Habilitar"}
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
>
{plugin.enabled
? <ToggleRight className="w-5 h-5 text-green-500" />
: <ToggleLeft className="w-5 h-5 text-black/30 dark:text-white/30" />
}
</button>
<button
onClick={() => void handleDelete()}
disabled={deleting}
title="Eliminar"
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-black/30 dark:text-white/30 hover:text-red-500 transition-colors disabled:opacity-50"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Página principal
// ─────────────────────────────────────────────────────────────────────────────
export default function PluginsPage() {
const [plugins, setPlugins] = useState<OrgPlugin[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await lmsApi.listPlugins();
setPlugins(data);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Error al cargar plugins");
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
const handleToggle = async (id: string, enabled: boolean) => {
try {
const updated = await lmsApi.updatePlugin(id, { enabled });
setPlugins(prev => prev.map(p => p.id === id ? updated : p));
} catch {
setError("Error al actualizar el plugin");
}
};
const handleDelete = async (id: string) => {
try {
await lmsApi.deletePlugin(id);
setPlugins(prev => prev.filter(p => p.id !== id));
} catch {
setError("Error al eliminar el plugin");
}
};
const handleCreated = (plugin: OrgPlugin) => {
setPlugins(prev => [...prev, plugin]);
setShowModal(false);
};
const active = plugins.filter(p => p.enabled);
const inactive = plugins.filter(p => !p.enabled);
return (
<div className="max-w-3xl mx-auto px-4 py-10 space-y-8">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Puzzle className="w-6 h-6 text-indigo-500" />
Ecosistema de Plugins
</h1>
<p className="text-sm text-black/40 dark:text-white/40 mt-1">
Registra y gestiona Web Components externos que se muestran como bloques en las lecciones.
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => void load()}
disabled={loading}
className="p-2 rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
</button>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm hover:bg-indigo-700 transition-colors"
>
<Plus className="w-4 h-4" />
Registrar Plugin
</button>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-700 dark:text-red-300">
<AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{/* Vacío */}
{!loading && plugins.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-black/30 dark:text-white/30">
<Puzzle className="w-12 h-12" />
<p className="text-sm text-center">Aún no hay plugins registrados.<br />Haz clic en <strong>Registrar Plugin</strong> para añadir el primero.</p>
</div>
)}
{/* Plugins activos */}
{active.length > 0 && (
<div className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wider text-black/40 dark:text-white/40 flex items-center gap-1">
<CheckCircle2 className="w-3.5 h-3.5 text-green-500" />
Activos ({active.length})
</h2>
{active.map(p => (
<PluginCard
key={p.id}
plugin={p}
onToggle={(id, en) => void handleToggle(id, en)}
onDelete={(id) => void handleDelete(id)}
/>
))}
</div>
)}
{/* Plugins inactivos */}
{inactive.length > 0 && (
<div className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wider text-black/40 dark:text-white/40">
Inactivos ({inactive.length})
</h2>
{inactive.map(p => (
<PluginCard
key={p.id}
plugin={p}
onToggle={(id, en) => void handleToggle(id, en)}
onDelete={(id) => void handleDelete(id)}
/>
))}
</div>
)}
{/* Info */}
<div className="rounded-xl border border-black/5 dark:border-white/5 bg-black/2 dark:bg-white/2 p-4 text-xs text-black/40 dark:text-white/40 space-y-1">
<p className="font-semibold text-black/50 dark:text-white/50">¿Cómo funciona?</p>
<p>1. Registra la URL HTTPS del Web Component externo.</p>
<p>2. En el Editor de Lecciones añade un bloque de tipo <code className="font-mono bg-black/5 dark:bg-white/5 px-1 rounded">plugin</code> y selecciona el plugin.</p>
<p>3. El componente se carga en un <code className="font-mono bg-black/5 dark:bg-white/5 px-1 rounded">iframe sandbox</code> los scripts externos nunca acceden al DOM de OpenCCB.</p>
</div>
{showModal && (
<CreatePluginModal onClose={() => setShowModal(false)} onCreated={handleCreated} />
)}
</div>
);
}
+31 -2
View File
@@ -71,6 +71,11 @@ export default function BackgroundTasksPage() {
style={{ width: `${progress}%` }}
></div>
<div className="text-[10px] text-gray-400 mt-1 font-medium">{progress}% completo</div>
{(task.processed_items > 0 || task.failed_items > 0) && (
<div className="text-[10px] text-gray-400 font-medium">
{task.processed_items} / {task.failed_items}
</div>
)}
</div>
)}
</div>
@@ -79,7 +84,21 @@ export default function BackgroundTasksPage() {
return <span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Queued</span>;
case 'failed':
case 'error':
return <span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Failed</span>;
return (
<div className="flex flex-col gap-1">
<span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Failed</span>
{task.error_message && (
<div className="text-[10px] text-red-500 max-w-[180px] leading-tight" title={task.error_message}>
{task.error_message.length > 60 ? task.error_message.slice(0, 60) + '…' : task.error_message}
</div>
)}
{task.task_type !== 'lesson_transcription' && (task.processed_items > 0 || task.failed_items > 0) && (
<div className="text-[10px] text-gray-400 font-medium">
{task.processed_items} ok / {task.failed_items} error
</div>
)}
</div>
);
case 'completed':
return <span className="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Completed</span>;
case 'idle':
@@ -179,7 +198,17 @@ export default function BackgroundTasksPage() {
Retry
</button>
)}
{task.task_type === 'lesson_transcription' && (
{(task.task_type === 'lesson_transcription' && (task.status === 'queued' || task.status === 'processing')) && (
<button
onClick={() => handleCancel(task.id)}
disabled={actionLoading === task.id}
className="inline-flex items-center px-3 py-1.5 border border-red-200 text-xs font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 disabled:opacity-50"
>
{actionLoading === task.id ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <XCircle className="w-3 h-3 mr-1" />}
Cancel
</button>
)}
{(task.task_type === 'zip_rag_import' && (task.status === 'queued' || task.status === 'processing')) && (
<button
onClick={() => handleCancel(task.id)}
disabled={actionLoading === task.id}
@@ -0,0 +1,371 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useParams } from "next/navigation";
import {
lmsApi,
CourseQualityMetrics,
CourseDiscriminationReport,
CurricularSuggestionsReport,
CurricularSuggestion,
LessonQualityMetric,
QuizDiscriminationItem,
} from "@/lib/api";
import CourseEditorLayout from "@/components/CourseEditorLayout";
import {
BarChart3,
TrendingDown,
TrendingUp,
Lightbulb,
AlertTriangle,
CheckCircle2,
Info,
Star,
RefreshCw,
} from "lucide-react";
// ─────────────────────────────────────────────────────────────────────────────
// Helpers de formato
// ─────────────────────────────────────────────────────────────────────────────
function pct(v: number) {
return `${(v * 100).toFixed(1)}%`;
}
function discriminationLabel(d: number): { label: string; color: string } {
if (d >= 0.4) return { label: "Excelente", color: "text-green-600 dark:text-green-400" };
if (d >= 0.3) return { label: "Buena", color: "text-blue-600 dark:text-blue-400" };
if (d >= 0.2) return { label: "Aceptable", color: "text-yellow-600 dark:text-yellow-400" };
return { label: "Revisar", color: "text-red-600 dark:text-red-400" };
}
function severityIcon(severity: string) {
switch (severity) {
case "high": return <AlertTriangle className="w-4 h-4 text-red-500 shrink-0" />;
case "medium": return <Info className="w-4 h-4 text-yellow-500 shrink-0" />;
case "positive": return <Star className="w-4 h-4 text-green-500 shrink-0" />;
default: return <Info className="w-4 h-4 text-blue-500 shrink-0" />;
}
}
function severityBadge(severity: string) {
const map: Record<string, string> = {
high: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300",
info: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
positive: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
};
const labels: Record<string, string> = {
high: "Alta prioridad",
medium: "Atención",
info: "Información",
positive: "Destacado",
};
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${map[severity] ?? map.info}`}>
{labels[severity] ?? severity}
</span>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Barra horizontal proporcional
// ─────────────────────────────────────────────────────────────────────────────
function Bar({ value, max = 1, colorClass = "bg-indigo-500" }: { value: number; max?: number; colorClass?: string }) {
const pctVal = Math.min(100, Math.max(0, (value / max) * 100));
return (
<div className="w-full h-2 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all duration-500 ${colorClass}`} style={{ width: `${pctVal}%` }} />
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Tabs
// ─────────────────────────────────────────────────────────────────────────────
type Tab = "quality" | "discrimination" | "suggestions";
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "quality", label: "Métricas de Calidad", icon: <BarChart3 className="w-4 h-4" /> },
{ id: "discrimination", label: "Índice de Discriminación", icon: <TrendingDown className="w-4 h-4" /> },
{ id: "suggestions", label: "Sugerencias Curriculares", icon: <Lightbulb className="w-4 h-4" /> },
];
// ─────────────────────────────────────────────────────────────────────────────
// Sección: Métricas de Calidad
// ─────────────────────────────────────────────────────────────────────────────
function QualityPanel({ data }: { data: CourseQualityMetrics }) {
return (
<div className="space-y-4">
<p className="text-sm text-black/50 dark:text-white/50">
{data.enrolled} alumno{data.enrolled !== 1 ? "s" : ""} inscrito{data.enrolled !== 1 ? "s" : ""} · {data.lessons.length} lección{data.lessons.length !== 1 ? "es" : ""}
</p>
{data.lessons.length === 0 && (
<p className="text-sm text-black/40 dark:text-white/40 italic">Sin datos de entregas todavía.</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm border-separate border-spacing-y-1">
<thead>
<tr className="text-left text-xs text-black/40 dark:text-white/40 uppercase tracking-wider">
<th className="pb-2 pr-4">#</th>
<th className="pb-2 pr-4">Lección</th>
<th className="pb-2 pr-4">Completitud</th>
<th className="pb-2 pr-4">Puntaje Medio</th>
<th className="pb-2 pr-4">Fallo</th>
<th className="pb-2 pr-4">Intentos Prom.</th>
<th className="pb-2">Sin entregar</th>
</tr>
</thead>
<tbody>
{data.lessons.map((l: LessonQualityMetric) => (
<tr key={l.lesson_id} className="bg-white/50 dark:bg-white/5 rounded-lg">
<td className="py-3 px-3 rounded-l-lg text-black/30 dark:text-white/30 w-8">{l.position}</td>
<td className="py-3 pr-4 font-medium max-w-[200px] truncate" title={l.lesson_title}>{l.lesson_title}</td>
<td className="py-3 pr-4">
<div className="space-y-1">
<span className="font-mono">{pct(l.completion_rate)}</span>
<Bar value={l.completion_rate} colorClass={l.completion_rate < 0.4 ? "bg-red-400" : "bg-indigo-500"} />
</div>
</td>
<td className="py-3 pr-4">
<div className="space-y-1">
<span className="font-mono">{pct(l.avg_score)}</span>
<Bar value={l.avg_score} colorClass={l.avg_score < 0.5 ? "bg-orange-400" : l.avg_score > 0.9 ? "bg-green-400" : "bg-sky-500"} />
</div>
</td>
<td className="py-3 pr-4 font-mono">
<span className={l.failure_rate > 0.4 ? "text-red-500 font-semibold" : ""}>{pct(l.failure_rate)}</span>
</td>
<td className="py-3 pr-4 font-mono">{l.avg_attempts.toFixed(1)}</td>
<td className="py-3 px-3 rounded-r-lg font-mono">
<span className={l.abandonment_count > 0 ? "text-yellow-600 dark:text-yellow-400" : "text-black/30 dark:text-white/30"}>{l.abandonment_count}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Sección: Índice de Discriminación
// ─────────────────────────────────────────────────────────────────────────────
function DiscriminationPanel({ data }: { data: CourseDiscriminationReport }) {
return (
<div className="space-y-4">
<p className="text-sm text-black/50 dark:text-white/50">
El índice mide si los alumnos con mejor rendimiento global aciertan más esta pregunta. Un índice 0.4 es excelente.
</p>
{data.items.length === 0 && (
<p className="text-sm text-black/40 dark:text-white/40 italic">
No hay datos de <code>block_scores</code> en los metadatos de entregas todavía.
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm border-separate border-spacing-y-1">
<thead>
<tr className="text-left text-xs text-black/40 dark:text-white/40 uppercase tracking-wider">
<th className="pb-2 pr-4">Lección</th>
<th className="pb-2 pr-4">Bloque</th>
<th className="pb-2 pr-4">Índice Discriminación</th>
<th className="pb-2 pr-4">Facilidad</th>
<th className="pb-2">Muestra</th>
</tr>
</thead>
<tbody>
{data.items.map((item: QuizDiscriminationItem) => {
const { label, color } = discriminationLabel(item.discrimination_index);
return (
<tr key={`${item.lesson_id}-${item.block_id}`} className="bg-white/50 dark:bg-white/5 rounded-lg">
<td className="py-3 px-3 rounded-l-lg font-medium max-w-[180px] truncate" title={item.lesson_title}>{item.lesson_title}</td>
<td className="py-3 pr-4 font-mono text-xs text-black/50 dark:text-white/50 max-w-[120px] truncate">{item.block_id}</td>
<td className="py-3 pr-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-mono">{item.discrimination_index.toFixed(2)}</span>
<span className={`text-xs font-semibold ${color}`}>{label}</span>
</div>
<Bar value={item.discrimination_index} max={1} colorClass={item.discrimination_index >= 0.3 ? "bg-green-500" : item.discrimination_index >= 0.2 ? "bg-yellow-400" : "bg-red-400"} />
</div>
</td>
<td className="py-3 pr-4">
<div className="space-y-1">
<span className="font-mono">{pct(item.facility_index)}</span>
<Bar value={item.facility_index} colorClass="bg-sky-500" />
</div>
</td>
<td className="py-3 px-3 rounded-r-lg font-mono text-black/40 dark:text-white/40">{item.sample_size}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Sección: Sugerencias Curriculares
// ─────────────────────────────────────────────────────────────────────────────
function SuggestionsPanel({ data }: { data: CurricularSuggestionsReport }) {
if (data.suggestions.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-black/30 dark:text-white/30">
<CheckCircle2 className="w-10 h-10" />
<p className="text-sm">No hay sugerencias en este momento. El curso tiene métricas saludables o aún pocos datos.</p>
</div>
);
}
const high = data.suggestions.filter(s => s.severity === "high");
const medium = data.suggestions.filter(s => s.severity === "medium");
const others = data.suggestions.filter(s => s.severity !== "high" && s.severity !== "medium");
const groups = [
{ label: "Alta prioridad", items: high },
{ label: "Atención", items: medium },
{ label: "Otras observaciones", items: others },
].filter(g => g.items.length > 0);
return (
<div className="space-y-6">
{groups.map(group => (
<div key={group.label} className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-black/40 dark:text-white/40">{group.label}</h3>
{group.items.map((s: CurricularSuggestion, i: number) => (
<div key={i} className="flex gap-3 p-4 rounded-xl bg-white/50 dark:bg-white/5 border border-black/5 dark:border-white/5">
{severityIcon(s.severity)}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate">{s.lesson_title}</span>
{severityBadge(s.severity)}
</div>
<p className="text-sm text-black/60 dark:text-white/60">{s.message}</p>
</div>
</div>
))}
</div>
))}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Página principal
// ─────────────────────────────────────────────────────────────────────────────
export default function PedagogicalAnalyticsPage() {
const { id } = useParams() as { id: string };
const [activeTab, setActiveTab] = useState<Tab>("quality");
const [quality, setQuality] = useState<CourseQualityMetrics | null>(null);
const [discrimination, setDiscrimination] = useState<CourseDiscriminationReport | null>(null);
const [suggestions, setSuggestions] = useState<CurricularSuggestionsReport | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [q, d, s] = await Promise.all([
lmsApi.getCourseQualityMetrics(id),
lmsApi.getCourseDiscriminationIndex(id),
lmsApi.getCourseSuggestions(id),
]);
setQuality(q);
setDiscrimination(d);
setSuggestions(s);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Error al cargar análisis pedagógico");
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { void load(); }, [load]);
return (
<CourseEditorLayout activeTab="pedagogical">
<div className="max-w-5xl mx-auto px-4 py-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<TrendingUp className="w-6 h-6 text-indigo-500" />
Análisis Pedagógico Profundo
</h1>
<p className="text-sm text-black/40 dark:text-white/40 mt-1">
Métricas de calidad, índice de discriminación y sugerencias de mejora curricular.
</p>
</div>
<button
onClick={() => void load()}
disabled={loading}
className="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
Actualizar
</button>
</div>
{/* Error */}
{error && (
<div className="p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* Tabs */}
<div className="flex gap-1 p-1 bg-black/5 dark:bg-white/5 rounded-xl w-fit">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
activeTab === tab.id
? "bg-white dark:bg-white/10 shadow-sm text-indigo-600 dark:text-indigo-400"
: "text-black/50 dark:text-white/50 hover:text-black/80 dark:hover:text-white/80"
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Content */}
<div className="rounded-2xl border border-black/5 dark:border-white/5 bg-white/30 dark:bg-white/5 p-6">
{loading && (
<div className="flex items-center justify-center py-20 text-black/30 dark:text-white/30">
<RefreshCw className="w-6 h-6 animate-spin mr-2" />
Cargando análisis...
</div>
)}
{!loading && activeTab === "quality" && quality && (
<QualityPanel data={quality} />
)}
{!loading && activeTab === "discrimination" && discrimination && (
<DiscriminationPanel data={discrimination} />
)}
{!loading && activeTab === "suggestions" && suggestions && (
<SuggestionsPanel data={suggestions} />
)}
</div>
</div>
</CourseEditorLayout>
);
}
@@ -24,7 +24,8 @@ type TabKey =
| "team"
| "peer-reviews"
| "students"
| "sessions";
| "sessions"
| "pedagogical";
interface CourseEditorLayoutProps {
children: React.ReactNode;
@@ -114,6 +115,7 @@ export default function CourseEditorLayout({
icon: TrendingUp,
tabs: [
{ key: "analytics", label: "Analíticas", icon: BarChart2, href: `/courses/${id}/analytics` },
{ key: "pedagogical", label: "Análisis Pedagógico", icon: TrendingUp, href: `/courses/${id}/analytics/pedagogical` },
{ key: "settings", label: "Configuración", icon: Settings, href: `/courses/${id}/settings` },
],
},
+91
View File
@@ -672,6 +672,47 @@ export interface CourseAnalytics {
}[];
}
// Análisis Pedagógico Profundo (Fase 34)
export interface LessonQualityMetric {
lesson_id: string;
lesson_title: string;
position: number;
completion_rate: number;
avg_attempts: number;
avg_score: number;
failure_rate: number;
abandonment_count: number;
}
export interface CourseQualityMetrics {
course_id: string;
enrolled: number;
lessons: LessonQualityMetric[];
}
export interface QuizDiscriminationItem {
lesson_id: string;
lesson_title: string;
block_id: string;
discrimination_index: number;
facility_index: number;
sample_size: number;
}
export interface CourseDiscriminationReport {
course_id: string;
items: QuizDiscriminationItem[];
}
export interface CurricularSuggestion {
lesson_id: string;
lesson_title: string;
kind: string;
message: string;
severity: string;
}
export interface CurricularSuggestionsReport {
course_id: string;
suggestions: CurricularSuggestion[];
}
export interface CohortData {
period: string;
count: number;
@@ -1769,6 +1810,24 @@ export const lmsApi = {
method: 'GET',
query: { days, limit }
}, true),
// Análisis Pedagógico Profundo (Fase 34)
getCourseQualityMetrics: (courseId: string): Promise<CourseQualityMetrics> =>
apiFetch(`/courses/${courseId}/pedagogical/quality-metrics`, {}, true),
getCourseDiscriminationIndex: (courseId: string): Promise<CourseDiscriminationReport> =>
apiFetch(`/courses/${courseId}/pedagogical/discrimination-index`, {}, true),
getCourseSuggestions: (courseId: string): Promise<CurricularSuggestionsReport> =>
apiFetch(`/courses/${courseId}/pedagogical/suggestions`, {}, true),
// Fase 35: Ecosistema de Plugins
listPlugins: (): Promise<OrgPlugin[]> =>
apiFetch('/plugins', {}, true),
createPlugin: (payload: CreatePluginPayload): Promise<OrgPlugin> =>
apiFetch('/plugins', { method: 'POST', body: JSON.stringify(payload) }, true),
updatePlugin: (id: string, payload: UpdatePluginPayload): Promise<OrgPlugin> =>
apiFetch(`/plugins/${id}`, { method: 'PUT', body: JSON.stringify(payload) }, true),
deletePlugin: (id: string): Promise<void> =>
apiFetch(`/plugins/${id}`, { method: 'DELETE' }, true),
};
export interface Meeting {
@@ -1916,6 +1975,35 @@ export interface AiDataEthicsSummaryResponse {
events: AiDataEthicsEventItem[];
}
// Fase 35: Ecosistema de Plugins
export interface OrgPlugin {
id: string;
organization_id: string;
name: string;
description: string;
component_url: string;
icon_url: string | null;
config: Record<string, unknown>;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface CreatePluginPayload {
name: string;
description?: string;
component_url: string;
icon_url?: string;
config?: Record<string, unknown>;
}
export interface UpdatePluginPayload {
name?: string;
description?: string;
component_url?: string;
icon_url?: string;
config?: Record<string, unknown>;
enabled?: boolean;
}
export interface LtiDeepLinkingContentItem {
type: 'ltiResourceLink';
title?: string;
@@ -1942,6 +2030,9 @@ export interface BackgroundTask {
task_type: 'lesson_transcription' | 'lesson_image' | 'course_image' | 'zip_rag_import';
status: 'idle' | 'queued' | 'processing' | 'failed' | 'completed' | 'error';
progress: number;
processed_items: number;
failed_items: number;
error_message?: string;
updated_at: string;
}
File diff suppressed because one or more lines are too long