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:
@@ -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);
|
||||
+2
@@ -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();
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user