Refactor code structure for improved readability and maintainability

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-27 13:55:06 -04:00
parent 8d606ddefc
commit 7a2afce796
10 changed files with 302 additions and 15 deletions
Generated
+1
View File
@@ -2235,6 +2235,7 @@ dependencies = [
"sqlx", "sqlx",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"tokio-stream",
"tower-http", "tower-http",
"tower_governor", "tower_governor",
"tracing", "tracing",
+7 -2
View File
@@ -117,7 +117,12 @@
--- ---
**Estado Actual**: Plataforma madura con IA generativa integrada, arquitectura Premium Single-Tenant, búsqueda semántica y monetización operativa. **Estado Actual**: Plataforma madura con IA generativa integrada, arquitectura Premium Single-Tenant, búsqueda semántica y monetización operativa.
### Fase 37: Tiempo Real y Seguridad Operacional ⚡
- [x] **SSE para Pizarras Colaborativas**: Reemplazado polling de 5s por `EventSource` en `CollaborativeWhiteboard.tsx`. Endpoint `GET /lessons/{id}/collaborative-canvas/stream` en `lms-service` usa canal `tokio::sync::mpsc` + `ReceiverStream`; el servidor consulta la DB cada 2s y emite eventos SSE solo cuando cambia la `revision`. El cliente cierra la conexión al desmontar el componente.
- [x] **Rotación de Secretos LTI**: Endpoint `POST /courses/{id}/lti-tools/{tool_id}/rotate-secret` genera un nuevo secreto alfanumérico de 32 chars, actualiza la DB y lo retorna una sola vez. UI en Studio (`/courses/[id]/lti-tools`) con botón 🔑 por herramienta, modal de confirmación de riesgo, y panel de copia-única del nuevo secreto con botón clipboard.
**Próximas Prioridades**: **Próximas Prioridades**:
1. Migrar passback LTI de HMAC custom a **OAuth2 AGS** (estándar IMS) manteniendo compatibilidad transitoria. 1. Migrar passback LTI de HMAC custom a **OAuth2 AGS** (estándar IMS) manteniendo compatibilidad transitoria.
2. Añadir **rotación/revocación de secretos** y auditoría de intentos fallidos de passback. 2. **Edición multiusuario** de documentos (tipo Google Docs) para Fase 33.
3. Evolucionar **Pizarras Compartidas** de polling a WebSocket/SSE tras validar carga en producción. 3. **Salas de Estudio** — grupos efímeros por video para resolución de dudas grupales.
+1
View File
@@ -32,5 +32,6 @@ aws-sdk-s3 = "1"
sha2.workspace = true sha2.workspace = true
hmac.workspace = true hmac.workspace = true
hex.workspace = true hex.workspace = true
tokio-stream = "0.1"
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-native-tls"] } lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-native-tls"] }
rand = "0.8" rand = "0.8"
+76
View File
@@ -4,6 +4,7 @@ use axum::{
http::{HeaderMap, header::AUTHORIZATION}, http::{HeaderMap, header::AUTHORIZATION},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
response::sse::{Event, KeepAlive, Sse},
Extension, Extension,
}; };
use aws_config::BehaviorVersion; use aws_config::BehaviorVersion;
@@ -4706,3 +4707,78 @@ fn extract_block_content(metadata: &Option<serde_json::Value>) -> String {
} }
block_content block_content
} }
// ─── SSE: Pizarra Colaborativa en Tiempo Real (Fase 37) ──────────────────────
pub async fn stream_lesson_collaborative_canvas(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, StatusCode> {
use std::convert::Infallible;
use tokio_stream::wrappers::ReceiverStream;
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!("stream_lesson_collaborative_canvas: lesson check failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
if !lesson_exists {
return Err(StatusCode::NOT_FOUND);
}
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(16);
tokio::spawn(async move {
#[derive(sqlx::FromRow)]
struct CanvasRow {
canvas_state: serde_json::Value,
revision: i64,
updated_at: DateTime<Utc>,
}
let mut last_revision: i64 = -1;
loop {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let result = sqlx::query_as::<_, CanvasRow>(
"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;
match result {
Ok(Some(row)) if row.revision != last_revision => {
last_revision = row.revision;
let payload = serde_json::json!({
"lesson_id": id,
"canvas_state": row.canvas_state,
"revision": row.revision,
"updated_at": row.updated_at.to_rfc3339(),
});
if tx.send(Ok(Event::default().data(payload.to_string()))).await.is_err() {
break; // Cliente desconectado
}
}
Ok(_) => {}
Err(e) => {
tracing::error!("stream_lesson_collaborative_canvas: poll error: {}", e);
let _ = tx.send(Ok(Event::default().event("error").data("poll_error"))).await;
}
}
}
});
Ok(Sse::new(ReceiverStream::new(rx))
.keep_alive(KeepAlive::default()))
}
@@ -430,3 +430,66 @@ pub async fn lti_grade_passback(
normalized_score: normalized, normalized_score: normalized,
})) }))
} }
// ─── Rotación de Secreto LTI (Fase 37) ───────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct RotateSecretResponse {
pub tool_id: Uuid,
pub new_secret: String,
pub rotated_at: chrono::DateTime<chrono::Utc>,
}
pub async fn rotate_lti_tool_secret(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
Path((course_id, tool_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<RotateSecretResponse>, (StatusCode, String)> {
use rand::Rng;
// Verificar que la herramienta pertenece al curso y organización
let tool_exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM lti_external_tools WHERE id = $1 AND course_id = $2 AND organization_id = $3)",
)
.bind(tool_id)
.bind(course_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !tool_exists {
return Err((StatusCode::NOT_FOUND, "Herramienta LTI no encontrada".to_string()));
}
// Generar nuevo secreto aleatorio de 32 caracteres alfanuméricos
let new_secret: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(32)
.map(char::from)
.collect();
let now = chrono::Utc::now();
sqlx::query(
"UPDATE lti_external_tools SET shared_secret = $1, updated_at = $2 WHERE id = $3",
)
.bind(&new_secret)
.bind(now)
.bind(tool_id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tracing::info!(
"rotate_lti_tool_secret: rotated secret for tool {} in course {} org {}",
tool_id, course_id, org_ctx.id
);
Ok(Json(RotateSecretResponse {
tool_id,
new_secret,
rotated_at: now,
}))
}
+8
View File
@@ -168,6 +168,10 @@ async fn main() {
get(handlers::get_lesson_collaborative_canvas) get(handlers::get_lesson_collaborative_canvas)
.put(handlers::update_lesson_collaborative_canvas), .put(handlers::update_lesson_collaborative_canvas),
) )
.route(
"/lessons/{id}/collaborative-canvas/stream",
get(handlers::stream_lesson_collaborative_canvas),
)
.route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark)) .route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark))
.route("/bookmarks", get(handlers::get_user_bookmarks)) .route("/bookmarks", get(handlers::get_user_bookmarks))
.route("/grades", post(handlers::submit_lesson_score)) .route("/grades", post(handlers::submit_lesson_score))
@@ -210,6 +214,10 @@ async fn main() {
put(handlers_lti_consumer::update_course_lti_tool) put(handlers_lti_consumer::update_course_lti_tool)
.delete(handlers_lti_consumer::delete_course_lti_tool), .delete(handlers_lti_consumer::delete_course_lti_tool),
) )
.route(
"/courses/{id}/lti-tools/{tool_id}/rotate-secret",
post(handlers_lti_consumer::rotate_lti_tool_secret),
)
// Portafolio e insignias (Badges) // Portafolio e insignias (Badges)
.route("/profile/{user_id}", get(portfolio::get_public_profile)) .route("/profile/{user_id}", get(portfolio::get_public_profile))
.route("/my/badges", get(portfolio::get_my_badges)) .route("/my/badges", get(portfolio::get_my_badges))
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
import { lmsApi, CollaborativeCanvasState } from "@/lib/api"; import { lmsApi, getLmsApiUrl, CollaborativeCanvasState } from "@/lib/api";
import { AlertTriangle, CheckCircle, Loader2, RefreshCw, Save, Trash2 } from "lucide-react"; import { AlertTriangle, CheckCircle, Loader2, RefreshCw, Save, Trash2 } from "lucide-react";
type ConflictInfo = { type ConflictInfo = {
@@ -218,23 +218,42 @@ export default function CollaborativeWhiteboard({ lessonId }: Props) {
}, [dirty, loading, saveCanvas, saving, strokes]); }, [dirty, loading, saveCanvas, saving, strokes]);
useEffect(() => { useEffect(() => {
const interval = setInterval(async () => { const base = getLmsApiUrl();
// getToken no es exportada; leemos directamente de sessionStorage/localStorage
const token =
(typeof window !== "undefined" &&
(sessionStorage.getItem("preview_token") || localStorage.getItem("experience_token"))) ||
"";
const url = `${base}/lessons/${lessonId}/collaborative-canvas/stream${token ? `?preview_token=${encodeURIComponent(token)}` : ""}`;
const es = new EventSource(url);
es.onmessage = (ev) => {
if (dirty || isDrawing.current) return; if (dirty || isDrawing.current) return;
try { try {
const data = await lmsApi.getLessonCollaborativeCanvas(lessonId); const data = JSON.parse(ev.data as string) as {
const serverStamp = data.updated_at || null; revision: number;
if (serverStamp !== lastSavedAt) { canvas_state: CollaborativeCanvasState;
updated_at: string;
};
if (data.revision !== revision) {
setStrokes(toStrokeArray(data.canvas_state || DEFAULT_CANVAS)); setStrokes(toStrokeArray(data.canvas_state || DEFAULT_CANVAS));
setLastSavedAt(serverStamp); setRevision(data.revision);
setRevision(data.revision || 0); setLastSavedAt(data.updated_at);
} }
} catch (e) { } catch {
console.error("Polling collaborative canvas failed", e); // Ignorar eventos malformados
} }
}, 5000); };
return () => clearInterval(interval); es.onerror = () => {
}, [dirty, lastSavedAt, lessonId]); // EventSource reintenta automáticamente; no mostramos error al usuario
};
return () => {
es.close();
};
}, [dirty, lessonId, revision]);
return ( return (
<section className="space-y-4 rounded-3xl border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-5"> <section className="space-y-4 rounded-3xl border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-5">
@@ -16,6 +16,9 @@ import {
ToggleLeft, ToggleLeft,
ToggleRight, ToggleRight,
ExternalLink, ExternalLink,
KeyRound,
Copy,
CheckCheck,
} from "lucide-react"; } from "lucide-react";
export default function CourseLtiToolsPage() { export default function CourseLtiToolsPage() {
@@ -31,6 +34,12 @@ export default function CourseLtiToolsPage() {
enabled: true, enabled: true,
}); });
// Rotación de secreto
const [rotateModal, setRotateModal] = useState<{ toolId: string; toolName: string } | null>(null);
const [rotatingId, setRotatingId] = useState<string | null>(null);
const [newSecret, setNewSecret] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const loadTools = useCallback(async () => { const loadTools = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -86,8 +95,104 @@ export default function CourseLtiToolsPage() {
} }
}; };
const confirmRotate = async () => {
if (!rotateModal) return;
setRotatingId(rotateModal.toolId);
setNewSecret(null);
setCopied(false);
try {
const result = await lmsApi.rotateCourseLtiToolSecret(id, rotateModal.toolId);
setNewSecret(result.new_secret);
} catch (e) {
setError(e instanceof Error ? e.message : "No se pudo rotar el secreto");
setRotateModal(null);
} finally {
setRotatingId(null);
}
};
const closeRotateModal = () => {
setRotateModal(null);
setNewSecret(null);
setCopied(false);
};
const copySecret = () => {
if (!newSecret) return;
void navigator.clipboard.writeText(newSecret);
setCopied(true);
setTimeout(() => setCopied(false), 2500);
};
return ( return (
<CourseEditorLayout activeTab="lti-tools" pageTitle="Herramientas LTI" pageDescription="Configura laboratorios externos y su passback de notas."> <CourseEditorLayout activeTab="lti-tools" pageTitle="Herramientas LTI" pageDescription="Configura laboratorios externos y su passback de notas.">
{/* Modal de rotación de secreto */}
{rotateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-zinc-900 border border-black/10 dark:border-white/10 shadow-2xl p-6 space-y-4">
<h2 className="text-sm font-black flex items-center gap-2">
<KeyRound className="w-4 h-4 text-amber-600" />
Rotar secreto {rotateModal.toolName}
</h2>
{!newSecret ? (
<>
<p className="text-xs text-black/60 dark:text-white/60">
Se generará un nuevo secreto aleatorio de 32 caracteres. El secreto actual dejará de funcionar inmediatamente. <strong>Actualiza tu herramienta LTI antes de confirmar.</strong>
</p>
<div className="flex gap-2 justify-end">
<button
onClick={closeRotateModal}
className="px-4 py-2 rounded-lg text-sm border border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/10"
>
Cancelar
</button>
<button
onClick={() => void confirmRotate()}
disabled={rotatingId !== null}
className="px-4 py-2 rounded-lg text-sm bg-amber-500 text-white font-semibold hover:bg-amber-600 disabled:opacity-50"
>
{rotatingId ? "Rotando..." : "Confirmar rotación"}
</button>
</div>
</>
) : (
<>
<div className="rounded-xl bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 p-4 space-y-2">
<p className="text-xs font-black text-green-800 dark:text-green-300">¡Secreto rotado exitosamente!</p>
<p className="text-[11px] text-green-700 dark:text-green-400">
Copia este secreto ahora. <strong>No se volverá a mostrar.</strong>
</p>
<div className="flex items-center gap-2 mt-2">
<code className="flex-1 rounded-lg bg-white dark:bg-black border border-black/10 dark:border-white/10 px-3 py-2 text-xs font-mono break-all">
{newSecret}
</code>
<button
onClick={copySecret}
className="p-2 rounded-lg border border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/10 shrink-0"
title="Copiar"
>
{copied ? (
<CheckCheck className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
<div className="flex justify-end">
<button
onClick={closeRotateModal}
className="px-4 py-2 rounded-lg text-sm bg-black text-white dark:bg-white dark:text-black font-semibold"
>
Cerrar
</button>
</div>
</>
)}
</div>
</div>
)}
<div className="space-y-6"> <div className="space-y-6">
<section className="rounded-2xl border border-black/10 dark:border-white/10 bg-white/60 dark:bg-white/5 p-5"> <section className="rounded-2xl border border-black/10 dark:border-white/10 bg-white/60 dark:bg-white/5 p-5">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -171,6 +276,13 @@ export default function CourseLtiToolsPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
onClick={() => setRotateModal({ toolId: tool.id, toolName: tool.name })}
className="p-2 rounded-lg hover:bg-amber-50 dark:hover:bg-amber-900/20"
title="Rotar secreto"
>
<KeyRound className="w-4 h-4 text-amber-600" />
</button>
<button <button
onClick={() => void toggleTool(tool)} onClick={() => void toggleTool(tool)}
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10" className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10"
+2
View File
@@ -1841,6 +1841,8 @@ export const lmsApi = {
apiFetch(`/courses/${courseId}/lti-tools/${toolId}`, { method: 'PUT', body: JSON.stringify(payload) }, true), apiFetch(`/courses/${courseId}/lti-tools/${toolId}`, { method: 'PUT', body: JSON.stringify(payload) }, true),
deleteCourseLtiTool: (courseId: string, toolId: string): Promise<void> => deleteCourseLtiTool: (courseId: string, toolId: string): Promise<void> =>
apiFetch(`/courses/${courseId}/lti-tools/${toolId}`, { method: 'DELETE' }, true), apiFetch(`/courses/${courseId}/lti-tools/${toolId}`, { method: 'DELETE' }, true),
rotateCourseLtiToolSecret: (courseId: string, toolId: string): Promise<{ tool_id: string; new_secret: string; rotated_at: string }> =>
apiFetch(`/courses/${courseId}/lti-tools/${toolId}/rotate-secret`, { method: 'POST' }, true),
}; };
export interface Meeting { export interface Meeting {
File diff suppressed because one or more lines are too long