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