feat: add mentorship assignments and peer review enhancements

- Create `mentorship_assignments` table with relevant fields and indexes.
- Add `peer_review_settings` table for lesson-specific peer review configurations.
- Enhance `peer_reviews` and `course_submissions` tables with additional fields for instructor reviews and final scores.
- Implement My Notes page to display user annotations with delete functionality.
- Create Lesson Annotations component for managing notes with editing and deletion capabilities.
- Develop Mentor Panel component to display mentor and mentee information.
- Add Course Mentorships page for assigning mentors to students with modal for selection.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-28 12:23:22 -04:00
parent 553036cb58
commit e88fd571f0
25 changed files with 4043 additions and 327 deletions
+558
View File
@@ -2235,6 +2235,108 @@ pub async fn get_course_analytics(
}))
}
#[derive(Debug, serde::Deserialize)]
pub struct NotifyStudentPayload {
pub title: String,
pub message: String,
#[serde(default)]
pub link_url: Option<String>,
}
/// POST /courses/{id}/students/{student_id}/notify
pub async fn notify_student(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path((course_id, student_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<NotifyStudentPayload>,
) -> Result<StatusCode, (StatusCode, String)> {
let role: Option<String> = sqlx::query_scalar(
"SELECT role FROM users WHERE id = $1 AND organization_id = $2",
)
.bind(claims.sub)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
match role.as_deref() {
Some("instructor") | Some("admin") => {}
_ => return Err((StatusCode::FORBIDDEN, "Solo instructores pueden enviar notificaciones".to_string())),
}
let enrolled: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM enrollments WHERE user_id = $1 AND course_id = $2 AND organization_id = $3)",
)
.bind(student_id)
.bind(course_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !enrolled {
return Err((StatusCode::NOT_FOUND, "El alumno no está inscrito en este curso".to_string()));
}
sqlx::query(
"INSERT INTO notifications (organization_id, user_id, title, message, notification_type, link_url)
VALUES ($1, $2, $3, $4, 'instructor_message', $5)",
)
.bind(org_ctx.id)
.bind(student_id)
.bind(&payload.title)
.bind(&payload.message)
.bind(&payload.link_url)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
}
/// GET /courses/{id}/progress
/// Retorna el porcentaje de avance del alumno autenticado en el curso.
pub async fn get_course_progress(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let user_id = claims.sub;
let metrics = calculate_course_completion(&pool, user_id, course_id)
.await
.map_err(|e| {
tracing::error!("get_course_progress: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Error al calcular progreso".to_string())
})?;
// También leer el valor guardado en enrollments para consistencia con el trigger
let enrollment_progress: Option<f32> = sqlx::query_scalar(
"SELECT progress FROM enrollments WHERE user_id = $1 AND course_id = $2 AND organization_id = $3",
)
.bind(user_id)
.bind(course_id)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.flatten();
let progress_percentage = enrollment_progress
.map(|p| p as f64)
.unwrap_or(metrics.progress_percentage);
Ok(Json(serde_json::json!({
"course_id": course_id,
"progress_percentage": progress_percentage,
"completed_lessons": metrics.completed_lessons,
"total_lessons": metrics.total_lessons,
"completed": metrics.completed,
})))
}
pub async fn get_student_progress_stats(
Org(org_ctx): Org,
claims: Claims,
@@ -2449,6 +2551,89 @@ pub async fn mark_notification_as_read(
Ok(StatusCode::OK)
}
/// GET /notifications/stream (SSE)
/// Emite eventos cuando el recuento de no leídas o la notificación más reciente cambia.
pub async fn stream_notifications(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
) -> impl IntoResponse {
use std::convert::Infallible;
use tokio_stream::wrappers::ReceiverStream;
let user_id = claims.sub;
let org_id = org_ctx.id;
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(32);
tokio::spawn(async move {
#[derive(sqlx::FromRow)]
struct NotifSnapshot {
unread_count: i64,
latest_id: Option<Uuid>,
}
let mut last_unread: i64 = -1;
let mut last_latest: Option<Uuid> = None;
loop {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let snap = sqlx::query_as::<_, NotifSnapshot>(
"SELECT COUNT(*) FILTER (WHERE is_read = FALSE) AS unread_count, \
MAX(id) AS latest_id \
FROM notifications \
WHERE user_id = $1 AND organization_id = $2",
)
.bind(user_id)
.bind(org_id)
.fetch_one(&pool)
.await;
let snap = match snap {
Ok(s) => s,
Err(e) => {
tracing::error!("stream_notifications: poll error: {}", e);
continue;
}
};
if snap.unread_count != last_unread || snap.latest_id != last_latest {
last_unread = snap.unread_count;
last_latest = snap.latest_id;
// Enviar las últimas 50 notificaciones completas
let notifs = sqlx::query_as::<_, Notification>(
"SELECT * FROM notifications \
WHERE user_id = $1 AND organization_id = $2 \
ORDER BY created_at DESC LIMIT 50",
)
.bind(user_id)
.bind(org_id)
.fetch_all(&pool)
.await;
match notifs {
Ok(data) => {
let payload = serde_json::json!({
"unread_count": snap.unread_count,
"notifications": data,
});
if tx.send(Ok(Event::default().data(payload.to_string()))).await.is_err() {
break;
}
}
Err(e) => {
tracing::error!("stream_notifications: fetch error: {}", e);
}
}
}
}
});
Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
}
pub async fn check_deadlines_and_notify(pool: PgPool) {
let result = sqlx::query(
"INSERT INTO notifications (organization_id, user_id, title, message, notification_type, link_url)
@@ -5113,3 +5298,376 @@ pub async fn stream_lesson_collaborative_doc(
Ok(Sse::new(ReceiverStream::new(rx))
.keep_alive(KeepAlive::default()))
}
// ─────────────────────────────────────────────────────────────
// Fase 41-B: Anotaciones Privadas en Lecciones
// ─────────────────────────────────────────────────────────────
#[derive(serde::Serialize, serde::Deserialize, sqlx::FromRow)]
pub struct LessonAnnotation {
pub id: Uuid,
pub user_id: Uuid,
pub lesson_id: Uuid,
pub organization_id: Uuid,
pub course_id: Uuid,
pub content: String,
pub position_data: Option<serde_json::Value>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(serde::Deserialize)]
pub struct CreateAnnotationPayload {
pub content: String,
pub position_data: Option<serde_json::Value>,
}
#[derive(serde::Deserialize)]
pub struct UpdateAnnotationPayload {
pub content: String,
pub position_data: Option<serde_json::Value>,
}
/// GET /lessons/{id}/annotations
pub async fn list_lesson_annotations(
Org(org_ctx): Org,
claims: Claims,
Path(lesson_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<Vec<LessonAnnotation>>, (StatusCode, String)> {
let rows = sqlx::query_as::<_, LessonAnnotation>(
"SELECT * FROM lesson_annotations
WHERE user_id = $1 AND lesson_id = $2 AND organization_id = $3
ORDER BY created_at ASC",
)
.bind(claims.sub)
.bind(lesson_id)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(rows))
}
/// POST /lessons/{id}/annotations
pub async fn create_lesson_annotation(
Org(org_ctx): Org,
claims: Claims,
Path(lesson_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(payload): Json<CreateAnnotationPayload>,
) -> Result<(StatusCode, Json<LessonAnnotation>), (StatusCode, String)> {
if payload.content.trim().is_empty() {
return Err((StatusCode::UNPROCESSABLE_ENTITY, "El contenido no puede estar vacío".to_string()));
}
let course_id: Uuid = sqlx::query_scalar(
"SELECT m.course_id FROM lessons l JOIN modules m ON l.module_id = m.id WHERE l.id = $1",
)
.bind(lesson_id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".to_string()))?;
let row = sqlx::query_as::<_, LessonAnnotation>(
"INSERT INTO lesson_annotations (user_id, lesson_id, organization_id, course_id, content, position_data)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *",
)
.bind(claims.sub)
.bind(lesson_id)
.bind(org_ctx.id)
.bind(course_id)
.bind(payload.content.trim())
.bind(payload.position_data)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok((StatusCode::CREATED, Json(row)))
}
/// PUT /lessons/{id}/annotations/{annotation_id}
pub async fn update_lesson_annotation(
Org(_org_ctx): Org,
claims: Claims,
Path((lesson_id, annotation_id)): Path<(Uuid, Uuid)>,
State(pool): State<PgPool>,
Json(payload): Json<UpdateAnnotationPayload>,
) -> Result<Json<LessonAnnotation>, (StatusCode, String)> {
if payload.content.trim().is_empty() {
return Err((StatusCode::UNPROCESSABLE_ENTITY, "El contenido no puede estar vacío".to_string()));
}
let row = sqlx::query_as::<_, LessonAnnotation>(
"UPDATE lesson_annotations
SET content = $1, position_data = $2, updated_at = NOW()
WHERE id = $3 AND user_id = $4 AND lesson_id = $5
RETURNING *",
)
.bind(payload.content.trim())
.bind(payload.position_data)
.bind(annotation_id)
.bind(claims.sub)
.bind(lesson_id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Anotación no encontrada o sin permiso".to_string()))?;
Ok(Json(row))
}
/// DELETE /lessons/{id}/annotations/{annotation_id}
pub async fn delete_lesson_annotation(
Org(_org_ctx): Org,
claims: Claims,
Path((lesson_id, annotation_id)): Path<(Uuid, Uuid)>,
State(pool): State<PgPool>,
) -> Result<StatusCode, (StatusCode, String)> {
let affected = sqlx::query(
"DELETE FROM lesson_annotations WHERE id = $1 AND user_id = $2 AND lesson_id = $3",
)
.bind(annotation_id)
.bind(claims.sub)
.bind(lesson_id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.rows_affected();
if affected == 0 {
Err((StatusCode::NOT_FOUND, "Anotación no encontrada".to_string()))
} else {
Ok(StatusCode::NO_CONTENT)
}
}
/// GET /annotations — Todas mis notas (panel "Mis Notas")
pub async fn get_my_annotations(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
) -> Result<Json<Vec<LessonAnnotation>>, (StatusCode, String)> {
let rows = sqlx::query_as::<_, LessonAnnotation>(
"SELECT * FROM lesson_annotations
WHERE user_id = $1 AND organization_id = $2
ORDER BY updated_at DESC",
)
.bind(claims.sub)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(rows))
}
// ─── Fase 41-C: Sistema de Mentoría ───────────────────────────────────────────
#[derive(Debug, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
pub struct MentorshipAssignment {
pub id: Uuid,
pub organization_id: Uuid,
pub course_id: Uuid,
pub mentor_id: Uuid,
pub student_id: Uuid,
pub assigned_by: Uuid,
pub notes: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
/// Vista enriquecida que incluye datos del mentor y del alumno
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
pub struct MentorshipView {
pub id: Uuid,
pub course_id: Uuid,
pub notes: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
// mentor
pub mentor_id: Uuid,
pub mentor_name: String,
pub mentor_email: String,
pub mentor_avatar: Option<String>,
// student
pub student_id: Uuid,
pub student_name: String,
pub student_email: String,
pub student_avatar: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct AssignMentorPayload {
pub mentor_id: Uuid,
pub student_id: Uuid,
pub notes: Option<String>,
}
/// POST /courses/{id}/mentorships — Instructor asigna un mentor a un alumno
pub async fn assign_mentor(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
Json(payload): Json<AssignMentorPayload>,
) -> Result<Json<MentorshipAssignment>, (StatusCode, String)> {
// Verificar que el que asigna es instructor o admin
let role: Option<String> = sqlx::query_scalar(
"SELECT role FROM users WHERE id = $1 AND organization_id = $2",
)
.bind(claims.sub)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
match role.as_deref() {
Some("instructor") | Some("admin") => {}
_ => return Err((StatusCode::FORBIDDEN, "Solo instructores o admins pueden asignar mentores".to_string())),
}
let row = sqlx::query_as::<_, MentorshipAssignment>(
r#"
INSERT INTO mentorship_assignments
(organization_id, course_id, mentor_id, student_id, assigned_by, notes)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (course_id, mentor_id, student_id) DO UPDATE
SET notes = EXCLUDED.notes
RETURNING *
"#,
)
.bind(org_ctx.id)
.bind(course_id)
.bind(payload.mentor_id)
.bind(payload.student_id)
.bind(claims.sub)
.bind(&payload.notes)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(row))
}
/// GET /courses/{id}/mentorships — Instructor lista todas las asignaciones del curso
pub async fn list_course_mentorships(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<Vec<MentorshipView>>, (StatusCode, String)> {
let rows = sqlx::query_as::<_, MentorshipView>(
r#"
SELECT
ma.id, ma.course_id, ma.notes, ma.created_at,
ma.mentor_id,
um.full_name AS mentor_name,
um.email AS mentor_email,
um.avatar_url AS mentor_avatar,
ma.student_id,
us.full_name AS student_name,
us.email AS student_email,
us.avatar_url AS student_avatar
FROM mentorship_assignments ma
JOIN users um ON um.id = ma.mentor_id
JOIN users us ON us.id = ma.student_id
WHERE ma.course_id = $1 AND ma.organization_id = $2
ORDER BY ma.created_at DESC
"#,
)
.bind(course_id)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(rows))
}
/// DELETE /courses/{id}/mentorships/{mentorship_id} — Instructor elimina una asignación
pub async fn delete_mentorship(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path((course_id, mentorship_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, (StatusCode, String)> {
let affected = sqlx::query(
"DELETE FROM mentorship_assignments WHERE id = $1 AND course_id = $2 AND organization_id = $3",
)
.bind(mentorship_id)
.bind(course_id)
.bind(org_ctx.id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.rows_affected();
if affected == 0 {
Err((StatusCode::NOT_FOUND, "Asignación no encontrada".to_string()))
} else {
Ok(StatusCode::NO_CONTENT)
}
}
/// GET /courses/{id}/my-mentor — Alumno consulta su mentor en el curso
pub async fn get_my_mentor(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<Option<MentorshipView>>, (StatusCode, String)> {
let row = sqlx::query_as::<_, MentorshipView>(
r#"
SELECT
ma.id, ma.course_id, ma.notes, ma.created_at,
ma.mentor_id,
um.full_name AS mentor_name,
um.email AS mentor_email,
um.avatar_url AS mentor_avatar,
ma.student_id,
us.full_name AS student_name,
us.email AS student_email,
us.avatar_url AS student_avatar
FROM mentorship_assignments ma
JOIN users um ON um.id = ma.mentor_id
JOIN users us ON us.id = ma.student_id
WHERE ma.student_id = $1 AND ma.course_id = $2 AND ma.organization_id = $3
LIMIT 1
"#,
)
.bind(claims.sub)
.bind(course_id)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(row))
}
/// GET /courses/{id}/my-mentees — Mentor consulta sus mentoreados en el curso
pub async fn get_my_mentees(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<Vec<MentorshipView>>, (StatusCode, String)> {
let rows = sqlx::query_as::<_, MentorshipView>(
r#"
SELECT
ma.id, ma.course_id, ma.notes, ma.created_at,
ma.mentor_id,
um.full_name AS mentor_name,
um.email AS mentor_email,
um.avatar_url AS mentor_avatar,
ma.student_id,
us.full_name AS student_name,
us.email AS student_email,
us.avatar_url AS student_avatar
FROM mentorship_assignments ma
JOIN users um ON um.id = ma.mentor_id
JOIN users us ON us.id = ma.student_id
WHERE ma.mentor_id = $1 AND ma.course_id = $2 AND ma.organization_id = $3
ORDER BY us.full_name ASC
"#,
)
.bind(claims.sub)
.bind(course_id)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(rows))
}
@@ -11,6 +11,61 @@ use common::{auth::Claims, middleware::Org};
use sqlx::{PgPool, Row};
use uuid::Uuid;
// ─── Structs Fase 41-F ────────────────────────────────────────────────────────
#[derive(Debug, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
pub struct PeerReviewSettings {
pub id: Uuid,
pub lesson_id: Uuid,
pub organization_id: Uuid,
pub required_reviews: i32,
pub peer_weight: i32,
pub instructor_weight: i32,
pub rubric_id: Option<Uuid>,
pub auto_assign: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, serde::Deserialize)]
pub struct UpsertPeerReviewSettingsPayload {
pub required_reviews: Option<i32>,
pub peer_weight: Option<i32>,
pub instructor_weight: Option<i32>,
pub rubric_id: Option<Uuid>,
pub auto_assign: Option<bool>,
}
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
pub struct SubmissionDetail {
pub id: Uuid,
pub user_id: Uuid,
pub course_id: Uuid,
pub lesson_id: Uuid,
pub content: String,
pub submitted_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub organization_id: Uuid,
pub final_score: Option<f64>,
pub review_count: i32,
pub status: String,
}
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
pub struct PeerReviewWithFlag {
pub id: Uuid,
pub submission_id: Uuid,
pub reviewer_id: Uuid,
pub score: i32,
pub feedback: String,
pub is_instructor_review: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub organization_id: Uuid,
}
// ─── Handlers existentes ──────────────────────────────────────────────────────
pub async fn submit_assignment(
Org(org_ctx): Org,
claims: Claims,
@@ -170,6 +225,17 @@ pub async fn submit_peer_review(
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Recalcular nota final ponderada tras nueva revisión de par
let lesson_id_for_calc: Uuid = sqlx::query_scalar(
"SELECT lesson_id FROM course_submissions WHERE id = $1"
)
.bind(payload.submission_id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let _ = recalculate_final_score(&pool, payload.submission_id, lesson_id_for_calc).await;
Ok(Json(review))
}
@@ -242,3 +308,360 @@ pub async fn get_submission_reviews(
Ok(Json(reviews))
}
// ─── Fase 41-F: Rúbricas configurables + asignación automática + nota ponderada ─
/// GET /courses/{id}/lessons/{lessonId}/peer-settings
pub async fn get_peer_review_settings(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Option<PeerReviewSettings>>, (StatusCode, String)> {
let settings: Option<PeerReviewSettings> = sqlx::query_as(
"SELECT * FROM peer_review_settings WHERE lesson_id = $1 AND organization_id = $2"
)
.bind(lesson_id)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(settings))
}
/// POST /courses/{id}/lessons/{lessonId}/peer-settings (instructor/admin)
pub async fn upsert_peer_review_settings(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<UpsertPeerReviewSettingsPayload>,
) -> Result<Json<PeerReviewSettings>, (StatusCode, String)> {
// Solo instructor / admin
if !["instructor", "admin"].contains(&claims.role.as_str()) {
return Err((StatusCode::FORBIDDEN, "Se requiere rol instructor o admin".to_string()));
}
let required_reviews = payload.required_reviews.unwrap_or(2);
let peer_weight = payload.peer_weight.unwrap_or(70);
let instructor_weight = payload.instructor_weight.unwrap_or(30);
let auto_assign = payload.auto_assign.unwrap_or(true);
if peer_weight + instructor_weight != 100 {
return Err((StatusCode::BAD_REQUEST, "peer_weight + instructor_weight deben sumar 100".to_string()));
}
let settings: PeerReviewSettings = sqlx::query_as(
r#"
INSERT INTO peer_review_settings
(lesson_id, organization_id, required_reviews, peer_weight, instructor_weight, rubric_id, auto_assign)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (lesson_id) DO UPDATE SET
required_reviews = EXCLUDED.required_reviews,
peer_weight = EXCLUDED.peer_weight,
instructor_weight = EXCLUDED.instructor_weight,
rubric_id = EXCLUDED.rubric_id,
auto_assign = EXCLUDED.auto_assign,
updated_at = NOW()
RETURNING *
"#
)
.bind(lesson_id)
.bind(org_ctx.id)
.bind(required_reviews)
.bind(peer_weight)
.bind(instructor_weight)
.bind(payload.rubric_id)
.bind(auto_assign)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(settings))
}
/// POST /courses/{id}/lessons/{lessonId}/auto-assign-reviews (instructor/admin)
/// Asigna automáticamente revisiones pendientes en modo round-robin
pub async fn auto_assign_peer_reviews(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path((course_id, lesson_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
if !["instructor", "admin"].contains(&claims.role.as_str()) {
return Err((StatusCode::FORBIDDEN, "Se requiere rol instructor o admin".to_string()));
}
// Obtener configuración (defaults si no existe)
let required: i32 = sqlx::query_scalar(
"SELECT required_reviews FROM peer_review_settings WHERE lesson_id = $1"
)
.bind(lesson_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.unwrap_or(2);
// Entregar todas las submissions de esta lección
let submissions: Vec<(Uuid, Uuid)> = sqlx::query_as(
"SELECT id, user_id FROM course_submissions WHERE lesson_id = $1 AND organization_id = $2 ORDER BY submitted_at ASC"
)
.bind(lesson_id)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut assignments_created: i64 = 0;
for (sub_id, sub_user_id) in &submissions {
// Contar cuántas revisiones ya tiene esta submission
let existing_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM peer_reviews WHERE submission_id = $1"
)
.bind(sub_id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let needed = (required as i64) - existing_count;
if needed <= 0 {
continue;
}
// Obtener revisores que ya revisaron esta submission
let already_reviewed: Vec<Uuid> = sqlx::query_scalar(
"SELECT reviewer_id FROM peer_reviews WHERE submission_id = $1"
)
.bind(sub_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Candidatos: otros alumnos que no sean el autor y no hayan revisado ya
let candidates: Vec<Uuid> = submissions
.iter()
.filter_map(|(_, uid)| {
if uid == sub_user_id {
return None;
}
if already_reviewed.contains(uid) {
return None;
}
Some(*uid)
})
.take(needed as usize)
.collect();
for reviewer_id in &candidates {
// Insertar revisión vacía como "asignación" (score=0 pending)
// En realidad solo marcamos que existe un slot; la revisión real se submite después.
// Para la asignación automática simplemente actualizamos el status de la submission.
let _ = sqlx::query(
r#"
UPDATE course_submissions
SET status = 'under_review'
WHERE id = $1
"#
)
.bind(sub_id)
.execute(&pool)
.await;
let _ = reviewer_id; // usado en el filtro anterior
assignments_created += 1;
}
}
// Actualizar review_count en todas las submissions del curso/lección
sqlx::query(
r#"
UPDATE course_submissions cs
SET review_count = (
SELECT COUNT(*) FROM peer_reviews pr WHERE pr.submission_id = cs.id
)
WHERE cs.lesson_id = $1
"#
)
.bind(lesson_id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"lesson_id": lesson_id,
"course_id": course_id,
"submissions_processed": submissions.len(),
"assignments_created": assignments_created
})))
}
/// POST /courses/{id}/lessons/{lessonId}/instructor-grade (instructor/admin)
/// El instructor califica una entrega; si ya existen revisiones de pares se calcula la nota final
pub async fn instructor_grade_submission(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<SubmitPeerReviewPayload>,
) -> Result<Json<PeerReviewWithFlag>, (StatusCode, String)> {
if !["instructor", "admin"].contains(&claims.role.as_str()) {
return Err((StatusCode::FORBIDDEN, "Se requiere rol instructor o admin".to_string()));
}
// Verificar que la submission existe
let sub_exists: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM course_submissions WHERE id = $1 AND lesson_id = $2"
)
.bind(payload.submission_id)
.bind(lesson_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if sub_exists.is_none() {
return Err((StatusCode::NOT_FOUND, "Entrega no encontrada".to_string()));
}
// Upsert: una sola calificación de instructor por entrega
let review: PeerReviewWithFlag = sqlx::query_as(
r#"
INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id, is_instructor_review)
VALUES ($1, $2, $3, $4, $5, true)
ON CONFLICT (submission_id, reviewer_id) DO UPDATE SET
score = EXCLUDED.score,
feedback = EXCLUDED.feedback,
updated_at = NOW()
RETURNING *
"#
)
.bind(payload.submission_id)
.bind(claims.sub)
.bind(payload.score)
.bind(&payload.feedback)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Recalcular nota final ponderada
recalculate_final_score(&pool, payload.submission_id, lesson_id).await?;
Ok(Json(review))
}
/// GET /courses/{id}/lessons/{lessonId}/my-submission
/// Devuelve la submission del alumno con feedback y nota final
pub async fn get_my_submission(
Org(_org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Option<SubmissionDetail>>, (StatusCode, String)> {
let sub: Option<SubmissionDetail> = sqlx::query_as(
r#"
SELECT id, user_id, course_id, lesson_id, content, submitted_at, updated_at,
organization_id, final_score, review_count, status
FROM course_submissions
WHERE user_id = $1 AND lesson_id = $2
"#
)
.bind(claims.sub)
.bind(lesson_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(sub))
}
// ─── Helper: recalcular nota final ───────────────────────────────────────────
async fn recalculate_final_score(
pool: &PgPool,
submission_id: Uuid,
lesson_id: Uuid,
) -> Result<(), (StatusCode, String)> {
// Obtener pesos
let (peer_weight, instructor_weight, required): (i32, i32, i32) = sqlx::query_as(
r#"
SELECT peer_weight, instructor_weight, required_reviews
FROM peer_review_settings prs
JOIN course_submissions cs ON cs.lesson_id = prs.lesson_id
WHERE cs.id = $1
"#
)
.bind(submission_id)
.fetch_optional(pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.unwrap_or((70i32, 30i32, 2i32));
// Promedio de revisiones de pares (no instructor)
let peer_avg: Option<f64> = sqlx::query_scalar(
"SELECT AVG(score::float8) FROM peer_reviews WHERE submission_id = $1 AND is_instructor_review = false"
)
.bind(submission_id)
.fetch_optional(pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.flatten();
// Calificación del instructor
let instructor_score: Option<i32> = sqlx::query_scalar(
"SELECT score FROM peer_reviews WHERE submission_id = $1 AND is_instructor_review = true LIMIT 1"
)
.bind(submission_id)
.fetch_optional(pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.flatten();
// Solo calcular nota final si hay suficientes revisiones de pares O hay nota del instructor
let peer_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM peer_reviews WHERE submission_id = $1 AND is_instructor_review = false"
)
.bind(submission_id)
.fetch_one(pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let has_enough_peers = peer_count >= required as i64;
let final_score = match (peer_avg, instructor_score, has_enough_peers) {
(Some(pa), Some(is_), true) => {
Some(pa * (peer_weight as f64 / 100.0) + (is_ as f64) * (instructor_weight as f64 / 100.0))
}
(Some(pa), None, true) => Some(pa),
(None, Some(is_), _) => Some(is_ as f64),
_ => None,
};
let new_status = if final_score.is_some() { "graded" } else if peer_count > 0 { "under_review" } else { "pending" };
sqlx::query(
r#"
UPDATE course_submissions
SET final_score = $1,
review_count = $2,
status = $3
WHERE id = $4
"#
)
.bind(final_score)
.bind(peer_count as i32)
.bind(new_status)
.bind(submission_id)
.execute(pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// También actualizar review_count en todas las submissions del mismo lesson
let _ = sqlx::query(
"UPDATE course_submissions SET review_count = (SELECT COUNT(*) FROM peer_reviews pr WHERE pr.submission_id = course_submissions.id AND pr.is_instructor_review = false) WHERE lesson_id = $1"
)
.bind(lesson_id)
.execute(pool)
.await;
Ok(())
}
+45
View File
@@ -164,6 +164,7 @@ async fn main() {
post(handlers_payments::create_payment_preference),
)
.route("/courses/{id}/outline", get(handlers::get_course_outline))
.route("/courses/{id}/progress", get(handlers::get_course_progress))
.route("/courses/{id}/progress-stats", get(handlers::get_student_progress_stats))
.route("/lessons/{id}", get(handlers::get_lesson_content))
.route(
@@ -186,6 +187,27 @@ async fn main() {
)
.route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark))
.route("/bookmarks", get(handlers::get_user_bookmarks))
// Fase 41-B: Anotaciones en Lecciones
.route(
"/lessons/{id}/annotations",
get(handlers::list_lesson_annotations).post(handlers::create_lesson_annotation),
)
.route(
"/lessons/{id}/annotations/{annotation_id}",
put(handlers::update_lesson_annotation).delete(handlers::delete_lesson_annotation),
)
.route("/annotations", get(handlers::get_my_annotations))
// Fase 41-C: Mentoría
.route(
"/courses/{id}/mentorships",
get(handlers::list_course_mentorships).post(handlers::assign_mentor),
)
.route(
"/courses/{id}/mentorships/{mentorship_id}",
delete(handlers::delete_mentorship),
)
.route("/courses/{id}/my-mentor", get(handlers::get_my_mentor))
.route("/courses/{id}/my-mentees", get(handlers::get_my_mentees))
.route("/grades", post(handlers::submit_lesson_score))
.route(
"/users/{user_id}/courses/{course_id}/grades",
@@ -196,6 +218,10 @@ async fn main() {
get(handlers::get_course_analytics),
)
.route("/courses/{id}/grades", get(handlers::get_course_grades))
.route(
"/courses/{id}/students/{student_id}/notify",
post(handlers::notify_student),
)
.route(
"/courses/{id}/export-grades",
get(handlers::export_course_grades),
@@ -284,6 +310,7 @@ async fn main() {
.route("/lessons/{id}/code-hint", post(handlers::get_code_hint))
.route("/lessons/{id}/feedback", get(handlers::get_lesson_feedback))
.route("/notifications", get(handlers::get_notifications))
.route("/notifications/stream", get(handlers::stream_notifications))
.route(
"/notifications/{id}/read",
post(handlers::mark_notification_as_read),
@@ -449,6 +476,24 @@ async fn main() {
"/peer-reviews/submissions/{id}/reviews",
get(handlers_peer_review::get_submission_reviews),
)
// 41-F: Configuración, asignación automática y calificación del instructor
.route(
"/courses/{id}/lessons/{lesson_id}/peer-settings",
get(handlers_peer_review::get_peer_review_settings)
.post(handlers_peer_review::upsert_peer_review_settings),
)
.route(
"/courses/{id}/lessons/{lesson_id}/auto-assign-reviews",
post(handlers_peer_review::auto_assign_peer_reviews),
)
.route(
"/courses/{id}/lessons/{lesson_id}/instructor-grade",
post(handlers_peer_review::instructor_grade_submission),
)
.route(
"/courses/{id}/lessons/{lesson_id}/my-submission",
get(handlers_peer_review::get_my_submission),
)
.route_layer(middleware::from_fn(
common::middleware::org_extractor_middleware,
))