feat: add collaborative document functionality for lessons

- Create migration scripts for lesson_collaborative_docs table in both cms-service and lms-service.
- Implement CollaborativeDocEditor component for real-time editing of collaborative documents.
- Add LessonCollaborativeDocPage for instructors to view and manage collaborative documents.
- Include conflict resolution handling in the editor.
- Enhance UI with status indicators and formatting options for the document editor.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-27 14:47:41 -04:00
parent 7de24469a3
commit 12d704a139
14 changed files with 1070 additions and 25 deletions
@@ -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);
@@ -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);
+273
View File
@@ -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<Uuid>,
pub updated_at: DateTime<Utc>,
}
#[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<String>,
pub server_revision: Option<i64>,
}
/// GET /lessons/{id}/collaborative-doc
pub async fn get_lesson_collaborative_doc(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<CollaborativeDocResponse>, 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<Uuid>,
updated_at: DateTime<Utc>,
}
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<PgPool>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateCollaborativeDocPayload>,
) -> Result<Json<UpdateCollaborativeDocResponse>, (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<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(|_| 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 DocRow {
content: String,
revision: i64,
last_modified_by: Option<Uuid>,
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::<_, 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()))
}
+9
View File
@@ -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))