diff --git a/roadmap.md b/roadmap.md index 6512961..7ad7f9d 100644 --- a/roadmap.md +++ b/roadmap.md @@ -128,7 +128,15 @@ - [x] **Studio**: página `/courses/[id]/study-rooms` — crear sala, lista con estado, botones Iniciar/Unirse (BBB en nueva pestaña)/Finalizar/Eliminar, instrucciones de configuración integradas. Tab "Salas de Estudio" en `CourseEditorLayout`. - [x] **Experience**: página `/courses/[id]/study-rooms` con lista de salas activas/programadas y botón "Unirse". Acceso directo desde la página del curso como tarjeta de navegación. +### Fase 39: Grabaciones BBB + OAuth2 AGS ✅ +- [x] **Grabaciones BBB**: `GET /courses/{id}/study-rooms/{room_id}/recordings` — llama a `getRecordings` de la API BBB y parsea XML de respuesta. Struct `BbbRecording` con `record_id`, `name`, `state`, `start_time`, `end_time`, `participants`, `playback_url`, `duration_minutes`. +- [x] **Studio study-rooms**: botón "Grabaciones" en salas finalizadas — panel expandible con lista de grabaciones + links de reproducción. +- [x] **Experience study-rooms**: sección de grabaciones en salas finalizadas con botón toggle y lista de grabaciones con links. +- [x] **OAuth2 AGS** (`handlers_lti_consumer.rs`): `POST /lti/tools/{tool_id}/ags-score` — token caching en tabla `lti_ags_tokens`, client_credentials grant, POST de scores al `ags_lineitem_url` del LMS externo. Modo dual: HMAC legacy sigue funcionando. +- [x] **Migración AGS**: ALTER TABLE `lti_external_tools` agrega campos `ags_client_id`, `ags_client_secret`, `ags_token_url`, `ags_lineitem_url`. CREATE TABLE `lti_ags_tokens`. +- [x] **Studio lti-tools**: sección AGS colapsable en el formulario de creación — 4 campos opcionales (client_id, client_secret, token_url, lineitem_url). +- [x] **api.ts**: Interface `BbbRecording`, `getStudyRoomRecordings()` en Studio y Experience. Campos AGS en `LtiExternalTool`, `CreateLtiExternalToolPayload`, `UpdateLtiExternalToolPayload`. + **Próximas Prioridades**: -1. **OAuth2 AGS** — estándar IMS para passback de calificaciones (reemplaza HMAC custom). -2. **Edición Multiusuario** — documentos compartidos tipo Google Docs en lecciones. -3. **Grabaciones BBB** — listar grabaciones de salas finalizadas desde la API de BBB. \ No newline at end of file +1. **Edición Multiusuario** — documentos compartidos tipo Google Docs en lecciones. +2. **Notificaciones en tiempo real** — WebSocket o SSE para alertas de actividad del curso. \ No newline at end of file diff --git a/services/cms-service/migrations/20260427000007_collaborative_docs.sql b/services/cms-service/migrations/20260427000007_collaborative_docs.sql new file mode 100644 index 0000000..a72acf1 --- /dev/null +++ b/services/cms-service/migrations/20260427000007_collaborative_docs.sql @@ -0,0 +1,15 @@ +-- Fase 40: Documentos Colaborativos (cms-service mirror) +CREATE TABLE IF NOT EXISTS lesson_collaborative_docs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + lesson_id UUID NOT NULL, + organization_id UUID NOT NULL, + course_id UUID NOT NULL, + content TEXT NOT NULL DEFAULT '', + revision BIGINT NOT NULL DEFAULT 0, + last_modified_by UUID, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_collab_docs_lesson + ON lesson_collaborative_docs (lesson_id, organization_id); diff --git a/services/lms-service/migrations/20260427000007_collaborative_docs.sql b/services/lms-service/migrations/20260427000007_collaborative_docs.sql new file mode 100644 index 0000000..5917a37 --- /dev/null +++ b/services/lms-service/migrations/20260427000007_collaborative_docs.sql @@ -0,0 +1,18 @@ +-- Fase 40: Documentos Colaborativos en Tiempo Real +CREATE TABLE IF NOT EXISTS lesson_collaborative_docs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + lesson_id UUID NOT NULL, + organization_id UUID NOT NULL, + course_id UUID NOT NULL, + content TEXT NOT NULL DEFAULT '', + revision BIGINT NOT NULL DEFAULT 0, + last_modified_by UUID, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_collab_docs_lesson + ON lesson_collaborative_docs (lesson_id, organization_id); + +CREATE INDEX IF NOT EXISTS idx_collab_docs_org + ON lesson_collaborative_docs (organization_id); diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index a8bb994..c0c4731 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -4782,3 +4782,276 @@ pub async fn stream_lesson_collaborative_canvas( Ok(Sse::new(ReceiverStream::new(rx)) .keep_alive(KeepAlive::default())) } + +// ─── Documentos Colaborativos en Tiempo Real (Fase 40) ─────────────────────── + +#[derive(Debug, Serialize)] +pub struct CollaborativeDocResponse { + pub lesson_id: Uuid, + pub organization_id: Uuid, + pub content: String, + pub revision: i64, + pub last_modified_by: Option, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateCollaborativeDocPayload { + pub content: String, + pub base_revision: i64, +} + +#[derive(Debug, Serialize)] +pub struct UpdateCollaborativeDocResponse { + pub lesson_id: Uuid, + pub revision: i64, + pub conflict: bool, + pub server_content: Option, + pub server_revision: Option, +} + +/// GET /lessons/{id}/collaborative-doc +pub async fn get_lesson_collaborative_doc( + Org(org_ctx): Org, + State(pool): State, + Path(id): Path, +) -> Result, 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(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if !lesson_exists { + return Err(StatusCode::NOT_FOUND); + } + + #[derive(sqlx::FromRow)] + struct DocRow { + content: String, + revision: i64, + last_modified_by: Option, + updated_at: DateTime, + } + + let row = sqlx::query_as::<_, DocRow>( + "SELECT content, revision, last_modified_by, updated_at FROM lesson_collaborative_docs WHERE lesson_id = $1 AND organization_id = $2", + ) + .bind(id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let doc = row.unwrap_or(DocRow { + content: String::new(), + revision: 0, + last_modified_by: None, + updated_at: Utc::now(), + }); + + Ok(Json(CollaborativeDocResponse { + lesson_id: id, + organization_id: org_ctx.id, + content: doc.content, + revision: doc.revision, + last_modified_by: doc.last_modified_by, + updated_at: doc.updated_at, + })) +} + +/// PUT /lessons/{id}/collaborative-doc +pub async fn update_lesson_collaborative_doc( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(id): Path, + Json(payload): Json, +) -> Result, (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| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if !lesson_exists { + return Err((StatusCode::NOT_FOUND, "Lección no encontrada".into())); + } + + let user_id: Uuid = claims.sub.parse().map_err(|_| (StatusCode::UNAUTHORIZED, "Token inválido".into()))?; + + // Intento de actualización optimista + let rows_updated = sqlx::query( + r#" + UPDATE lesson_collaborative_docs + SET content = $1, revision = revision + 1, last_modified_by = $2, updated_at = NOW() + WHERE lesson_id = $3 AND organization_id = $4 AND revision = $5 + "#, + ) + .bind(&payload.content) + .bind(user_id) + .bind(id) + .bind(org_ctx.id) + .bind(payload.base_revision) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .rows_affected(); + + if rows_updated == 1 { + return Ok(Json(UpdateCollaborativeDocResponse { + lesson_id: id, + revision: payload.base_revision + 1, + conflict: false, + server_content: None, + server_revision: None, + })); + } + + // Verificar si existe (puede ser primer guardado con revision=0) + let existing = sqlx::query_scalar::<_, i64>( + "SELECT revision FROM lesson_collaborative_docs WHERE lesson_id = $1 AND organization_id = $2", + ) + .bind(id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if existing.is_none() && payload.base_revision == 0 { + // Primer guardado + let course_id = sqlx::query_scalar::<_, Uuid>( + "SELECT course_id FROM lessons WHERE id = $1", + ) + .bind(id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + sqlx::query( + r#" + INSERT INTO lesson_collaborative_docs (lesson_id, organization_id, course_id, content, revision, last_modified_by) + VALUES ($1, $2, $3, $4, 1, $5) + "#, + ) + .bind(id) + .bind(org_ctx.id) + .bind(course_id) + .bind(&payload.content) + .bind(user_id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + return Ok(Json(UpdateCollaborativeDocResponse { + lesson_id: id, + revision: 1, + conflict: false, + server_content: None, + server_revision: None, + })); + } + + // Conflicto — devolver versión del servidor + #[derive(sqlx::FromRow)] + struct ConflictRow { content: String, revision: i64 } + let server = sqlx::query_as::<_, ConflictRow>( + "SELECT content, revision FROM lesson_collaborative_docs WHERE lesson_id = $1 AND organization_id = $2", + ) + .bind(id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let (sc, sr) = server.map(|r| (r.content, r.revision)).unwrap_or_default(); + + Err(( + StatusCode::CONFLICT, + serde_json::json!({ + "conflict": true, + "lesson_id": id, + "server_content": sc, + "server_revision": sr, + }).to_string(), + )) +} + +/// GET /lessons/{id}/collaborative-doc/stream (SSE) +pub async fn stream_lesson_collaborative_doc( + Org(org_ctx): Org, + State(pool): State, + Path(id): Path, +) -> Result { + 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(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if !lesson_exists { + return Err(StatusCode::NOT_FOUND); + } + + let (tx, rx) = tokio::sync::mpsc::channel::>(16); + + tokio::spawn(async move { + #[derive(sqlx::FromRow)] + struct DocRow { + content: String, + revision: i64, + last_modified_by: Option, + updated_at: DateTime, + } + + let mut last_revision: i64 = -1; + + loop { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let result = sqlx::query_as::<_, DocRow>( + "SELECT content, revision, last_modified_by, updated_at FROM lesson_collaborative_docs 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, + "content": row.content, + "revision": row.revision, + "last_modified_by": row.last_modified_by, + "updated_at": row.updated_at.to_rfc3339(), + }); + if tx.send(Ok(Event::default().data(payload.to_string()))).await.is_err() { + break; + } + } + Ok(_) => {} + Err(e) => { + tracing::error!("stream_lesson_collaborative_doc: 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())) +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index e3c5ea7..5703e1e 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -173,6 +173,15 @@ async fn main() { "/lessons/{id}/collaborative-canvas/stream", get(handlers::stream_lesson_collaborative_canvas), ) + .route( + "/lessons/{id}/collaborative-doc", + get(handlers::get_lesson_collaborative_doc) + .put(handlers::update_lesson_collaborative_doc), + ) + .route( + "/lessons/{id}/collaborative-doc/stream", + get(handlers::stream_lesson_collaborative_doc), + ) .route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark)) .route("/bookmarks", get(handlers::get_user_bookmarks)) .route("/grades", post(handlers::submit_lesson_score)) diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index ebf370e..5faca9d 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -29,6 +29,7 @@ import AITutor from "@/components/AITutor"; import LessonLockedView from "@/components/LessonLockedView"; import StudentNotes from "@/components/StudentNotes"; import CollaborativeWhiteboard from "@/components/CollaborativeWhiteboard"; +import CollaborativeDocEditor from "@/components/CollaborativeDocEditor"; import { ListMusic, StickyNote } from "lucide-react"; import ReactMarkdown from "react-markdown"; export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) { @@ -631,6 +632,14 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
+ +
+
+

Documento Colaborativo

+

Edición compartida en tiempo real con tu grupo

+
+ +
diff --git a/web/experience/src/app/courses/[id]/study-rooms/page.tsx b/web/experience/src/app/courses/[id]/study-rooms/page.tsx index d1d957a..f65775b 100644 --- a/web/experience/src/app/courses/[id]/study-rooms/page.tsx +++ b/web/experience/src/app/courses/[id]/study-rooms/page.tsx @@ -2,8 +2,8 @@ import { useCallback, useEffect, useState } from "react"; import { useParams, useRouter } from "next/navigation"; -import { lmsApi, StudyRoom } from "@/lib/api"; -import { Video, Users, Clock, ExternalLink, ArrowLeft, RefreshCw } from "lucide-react"; +import { lmsApi, StudyRoom, BbbRecording } from "@/lib/api"; +import { Video, Users, Clock, ExternalLink, ArrowLeft, RefreshCw, Film, ChevronDown, ChevronRight } from "lucide-react"; const STATUS_LABEL: Record = { pending: "Programada", @@ -23,6 +23,9 @@ export default function StudyRoomsPage() { const [loading, setLoading] = useState(true); const [joiningId, setJoiningId] = useState(null); const [error, setError] = useState(null); + const [recordings, setRecordings] = useState>({}); + const [loadingRec, setLoadingRec] = useState>({}); + const [expandedRec, setExpandedRec] = useState>({}); const loadRooms = useCallback(async () => { setLoading(true); @@ -58,6 +61,22 @@ export default function StudyRoomsPage() { const activeRooms = rooms.filter((r) => r.status !== "ended"); const endedRooms = rooms.filter((r) => r.status === "ended"); + const toggleRecordings = async (room: StudyRoom) => { + const isExpanded = expandedRec[room.id]; + setExpandedRec((prev) => ({ ...prev, [room.id]: !isExpanded })); + if (!isExpanded && !recordings[room.id]) { + setLoadingRec((prev) => ({ ...prev, [room.id]: true })); + try { + const recs = await lmsApi.getStudyRoomRecordings(id, room.id); + setRecordings((prev) => ({ ...prev, [room.id]: recs })); + } catch { + setRecordings((prev) => ({ ...prev, [room.id]: [] })); + } finally { + setLoadingRec((prev) => ({ ...prev, [room.id]: false })); + } + } + }; + return (
@@ -156,21 +175,57 @@ export default function StudyRoomsPage() { Salas finalizadas {endedRooms.map((room) => ( -
-
- {room.title} - {room.ended_at && ( - - Finalizada {new Date(room.ended_at).toLocaleDateString()} +
+
+
+ {room.title} + {room.ended_at && ( + + Finalizada {new Date(room.ended_at).toLocaleDateString()} + + )} +
+
+ + + {STATUS_LABEL.ended} - )} +
- - {STATUS_LABEL.ended} - + {expandedRec[room.id] && ( +
+ {loadingRec[room.id] ? ( +

Cargando grabaciones…

+ ) : recordings[room.id]?.length ? ( +
+ {recordings[room.id].map((rec) => ( +
+
+ {rec.name} + {rec.duration_minutes} min +
+ + Ver grabación + +
+ ))} +
+ ) : ( +

No hay grabaciones disponibles.

+ )} +
+ )}
))} diff --git a/web/experience/src/components/CollaborativeDocEditor.tsx b/web/experience/src/components/CollaborativeDocEditor.tsx new file mode 100644 index 0000000..93d22be --- /dev/null +++ b/web/experience/src/components/CollaborativeDocEditor.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { lmsApi, getLmsApiUrl, CollaborativeDoc, UpdateCollaborativeDocResponse } from "@/lib/api"; +import { + AlertTriangle, + CheckCircle, + Loader2, + Save, + Users, +} from "lucide-react"; + +type ConflictInfo = { + localContent: string; + serverContent: string; + serverRevision: number; +}; + +type Props = { + lessonId: string; +}; + +export default function CollaborativeDocEditor({ lessonId }: Props) { + const [content, setContent] = useState(""); + const [revision, setRevision] = useState(0); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState<"idle" | "saved" | "conflict" | "error">("idle"); + const [conflict, setConflict] = useState(null); + const [activeEditors, setActiveEditors] = useState(0); + const debounceRef = useRef | null>(null); + const sseRef = useRef(null); + const localDirtyRef = useRef(false); + const revisionRef = useRef(0); + const contentRef = useRef(""); + + // Cargar documento inicial + useEffect(() => { + void (async () => { + try { + const doc: CollaborativeDoc = await lmsApi.getLessonCollaborativeDoc(lessonId); + setContent(doc.content); + setRevision(doc.revision); + revisionRef.current = doc.revision; + contentRef.current = doc.content; + } catch { + setStatus("error"); + } + })(); + }, [lessonId]); + + // SSE: escuchar cambios remotos + useEffect(() => { + const token = typeof window !== "undefined" + ? localStorage.getItem("lms_token") ?? sessionStorage.getItem("lms_token") ?? "" + : ""; + const baseUrl = getLmsApiUrl(); + const url = `${baseUrl}/lessons/${lessonId}/collaborative-doc/stream?preview_token=${encodeURIComponent(token)}`; + + const es = new EventSource(url); + sseRef.current = es; + + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data as string) as { + content: string; + revision: number; + }; + // Solo actualizar si no tenemos cambios locales pendientes + if (!localDirtyRef.current && data.revision !== revisionRef.current) { + setContent(data.content); + setRevision(data.revision); + revisionRef.current = data.revision; + contentRef.current = data.content; + } + // Contar "editores activos" como señal de conexión SSE viva + setActiveEditors((n) => Math.max(1, n)); + } catch { /* ignore */ } + }; + + es.onerror = () => { + setActiveEditors(0); + }; + + return () => { + es.close(); + sseRef.current = null; + }; + }, [lessonId]); + + // Guardar con debounce 1.5s + const save = useCallback(async (text: string, baseRevision: number) => { + setSaving(true); + setStatus("idle"); + try { + const res: UpdateCollaborativeDocResponse = await lmsApi.updateLessonCollaborativeDoc(lessonId, { + content: text, + base_revision: baseRevision, + }); + + if (res.conflict) { + setConflict({ + localContent: text, + serverContent: res.server_content ?? "", + serverRevision: res.server_revision ?? baseRevision, + }); + setStatus("conflict"); + } else { + setRevision(res.revision); + revisionRef.current = res.revision; + contentRef.current = text; + localDirtyRef.current = false; + setStatus("saved"); + setTimeout(() => setStatus("idle"), 2000); + } + } catch { + setStatus("error"); + } finally { + setSaving(false); + } + }, [lessonId]); + + const handleChange = (value: string) => { + setContent(value); + contentRef.current = value; + localDirtyRef.current = true; + setStatus("idle"); + + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + void save(contentRef.current, revisionRef.current); + }, 1500); + }; + + // Resolución de conflictos + const resolveKeepMine = () => { + if (!conflict) return; + setConflict(null); + setStatus("idle"); + // Forzar guardado con la revisión del servidor (sobreescribir) + void save(conflict.localContent, conflict.serverRevision); + }; + + const resolveKeepServer = () => { + if (!conflict) return; + setContent(conflict.serverContent); + setRevision(conflict.serverRevision); + revisionRef.current = conflict.serverRevision; + contentRef.current = conflict.serverContent; + localDirtyRef.current = false; + setConflict(null); + setStatus("idle"); + }; + + // Toolbar: aplicar formato HTML básico via execCommand (compatible con contenteditable) + // Para mantener simpleza y no depender de librerías externas usamos un