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
+13 -4
View File
@@ -63,10 +63,19 @@ async fn main() {
// Inicializar el estado de salud
let health_state = HealthState::default();
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Error al ejecutar las migraciones");
if let Err(err) = sqlx::migrate!("./migrations").run(&pool).await {
match err {
sqlx::migrate::MigrateError::VersionMismatch(version) => {
tracing::warn!(
"Se detecto VersionMismatch en migracion {}. Se continua el arranque para no interrumpir el servicio.",
version
);
}
_ => {
panic!("Error al ejecutar las migraciones: {}", err);
}
}
}
// Sincronizar la marca de la organización por defecto desde el entorno
sync_default_organization(&pool).await;
+373 -50
View File
@@ -4,73 +4,246 @@ use utoipa::OpenApi;
#[derive(utoipa::ToSchema, serde::Serialize, serde::Deserialize)]
pub struct ExternalCreateCoursePayloadSchema {
/// Obligatorio. No debe ser vacío.
/// Obligatorio. No debe ser vacio.
pub title: String,
/// Opcional: puede omitirse o enviarse como `null`.
/// Opcional: puede omitirse o enviarse como null.
pub description: Option<String>,
/// Opcional: puede omitirse o enviarse como `null`.
/// Opcional: puede omitirse o enviarse como null.
pub pacing_mode: Option<String>,
/// Opcional: idCursoAbierto del sistema SAM.
/// También se acepta como `idcursoabierto` o `id_curso_abierto` en el payload real.
/// Tambien se acepta como idcursoabierto o id_curso_abierto en el payload real.
pub external_sam_id: Option<i64>,
/// Opcional: UUID de plantilla específica.
pub template_id: Option<String>,
/// Opcional: fallback de selección de plantilla por nivel.
pub template_level: Option<String>,
/// Opcional: fallback de selección de plantilla por tipo de curso.
pub template_course_type: Option<String>,
/// Opcional: fallback de selección de plantilla por tipo de test.
pub template_test_type: Option<String>,
/// Opcional: título del módulo creado al aplicar plantilla.
pub module_title: Option<String>,
/// Opcional: título de la lección creada al aplicar plantilla.
pub lesson_title: Option<String>,
}
#[derive(utoipa::ToSchema, serde::Serialize)]
pub struct ExternalCourseEnvelopeSchema {
pub course: serde_json::Value,
pub template_applied: bool,
pub template_id: Option<String>,
pub lesson_id: Option<String>,
}
#[derive(OpenApi)]
#[openapi(
info(
title = "OpenCCB CMS API",
version = "1.0.0",
description = "API del CMS para gestión interna y endpoints externos por API key.\n\nReglas de nulabilidad y arreglos (aplican a todos los endpoints de esta API):\n- Campos opcionales (Option): pueden omitirse o enviarse como null.\n- Campos de arreglo no opcionales (Vec): deben enviarse como arreglo; pueden ser [] pero no null.\n- Objetos requeridos del payload: no deben enviarse como null."
version = "1.1.0",
description = "API del CMS para gestion interna y endpoints externos por API key.\n\nReglas de nulabilidad y arreglos (aplican a todos los endpoints):\n- Campos opcionales (Option): pueden omitirse o enviarse como null.\n- Campos de arreglo no opcionales (Vec): deben enviarse como arreglo; pueden ser [] pero no null.\n- Objetos requeridos del payload: no deben enviarse como null."
),
paths(
register,
login,
sso_login_init,
sso_callback,
get_branding,
public_s3_proxy,
create_course_external,
get_course_external,
trigger_transcription_external,
get_courses,
create_course,
get_course,
update_course,
delete_course,
publish_course,
get_course_outline,
get_course_analytics,
get_advanced_analytics,
get_course_team,
add_team_member,
remove_team_member,
create_course_preview_token,
get_lesson_heatmap,
get_modules,
create_module,
reorder_modules,
update_module,
delete_module,
get_lessons,
create_lesson,
reorder_lessons,
get_lesson,
update_lesson,
delete_lesson,
process_transcription,
get_lesson_vtt,
summarize_lesson,
generate_quiz,
generate_role_play,
generate_hotspots,
generate_mermaid_diagram,
generate_code_lab,
generate_course,
export_course,
import_course,
list_course_templates,
create_course_template_from_course,
apply_course_template,
delete_course_template,
create_grading_category,
delete_grading_category,
get_grading_categories,
get_tipo_nota,
get_me,
get_all_users,
admin_create_user,
update_user,
delete_user,
get_audit_logs,
review_text,
list_assets,
list_asset_import_history,
upload_asset,
import_assets_zip,
ingest_asset_for_rag,
delete_asset,
get_webhooks,
create_webhook,
delete_webhook,
get_background_tasks,
retry_task,
cancel_task,
get_organization,
get_sso_config,
update_sso_config,
upload_organization_logo,
upload_organization_favicon,
update_organization_branding,
get_organization_exercise_settings,
update_organization_exercise_settings,
get_organization_email_settings,
update_organization_email_settings,
list_organization_email_services,
create_organization_email_service,
update_organization_email_service,
delete_organization_email_service,
select_organization_email_service,
list_organization_email_templates,
create_organization_email_template,
update_organization_email_template,
delete_organization_email_template,
list_library_blocks,
create_library_block,
get_library_block,
update_library_block,
delete_library_block,
increment_block_usage,
list_course_rubrics,
create_rubric,
get_rubric_with_details,
update_rubric,
delete_rubric,
create_criterion,
update_criterion,
delete_criterion,
create_level,
update_level,
delete_level,
assign_rubric_to_lesson,
unassign_rubric_from_lesson,
get_lesson_rubrics,
list_lesson_dependencies,
assign_dependency,
remove_dependency,
list_test_templates,
create_test_template,
get_test_template,
update_test_template,
delete_test_template,
create_template_question,
delete_template_question,
create_template_section,
delete_template_section,
apply_template_to_lesson,
generate_questions_with_rag,
list_questions,
create_question,
get_question,
update_question,
delete_question,
import_from_mysql,
get_mysql_plans,
get_mysql_courses_by_plan,
import_all_from_mysql,
import_course_from_mysql,
import_from_sam_diagnostico,
ai_generate_question,
generate_question_embeddings,
semantic_search,
find_similar_questions,
regenerate_question_embedding,
sync_all_sam,
sync_sam_students,
sync_sam_assignments,
list_sam_students,
get_sam_student_courses,
get_token_usage,
get_ai_usage_global,
set_user_token_limit,
get_user_token_usage,
check_user_token_limit,
list_plugins,
create_plugin,
list_enabled_plugins,
update_plugin,
delete_plugin,
),
components(
schemas(
ExternalCreateCoursePayloadSchema,
ExternalCourseEnvelopeSchema,
)
schemas(ExternalCreateCoursePayloadSchema)
),
tags(
(name = "External", description = "Integración externa del CMS")
(name = "Auth", description = "Autenticacion y SSO"),
(name = "External", description = "Integracion externa con API key"),
(name = "Courses", description = "Gestion de cursos y estructura"),
(name = "Organization", description = "Configuracion de organizacion"),
(name = "Assets", description = "Activos y librerias"),
(name = "Rubrics", description = "Rubricas y criterios"),
(name = "Templates", description = "Plantillas de curso y test"),
(name = "QuestionBank", description = "Banco de preguntas y embeddings"),
(name = "SAM", description = "Integracion con SAM"),
(name = "Admin", description = "Administracion"),
(name = "Plugins", description = "Ecosistema de plugins")
)
)]
pub struct ApiDoc;
macro_rules! ok_path {
($name:ident, $method:ident, $path:literal, $tag:literal) => {
#[utoipa::path(
$method,
path = $path,
tag = $tag,
responses((status = 200, description = "OK"))
)]
pub fn $name() {}
};
}
macro_rules! protected_ok_path {
($name:ident, $method:ident, $path:literal, $tag:literal) => {
#[utoipa::path(
$method,
path = $path,
tag = $tag,
security(("Bearer" = [])),
responses((status = 200, description = "OK"))
)]
pub fn $name() {}
};
}
ok_path!(register, post, "/auth/register", "Auth");
ok_path!(login, post, "/auth/login", "Auth");
ok_path!(sso_login_init, get, "/auth/sso/login/{org_id}", "Auth");
ok_path!(sso_callback, get, "/auth/sso/callback", "Auth");
ok_path!(get_branding, get, "/branding", "Organization");
ok_path!(public_s3_proxy, get, "/api/assets/s3-proxy/{bucket}/{key}", "Assets");
#[utoipa::path(
post,
path = "/api/external/v1/courses",
tag = "External",
request_body = ExternalCreateCoursePayloadSchema,
responses(
(status = 200, description = "Curso creado exitosamente"),
(status = 400, description = "Payload inválido o plantilla no encontrada"),
(status = 401, description = "X-API-Key inválida o ausente"),
(status = 500, description = "Error interno del servidor")
),
security(("ApiKey" = []))
security(("ApiKey" = [])),
responses((status = 200, description = "Curso creado"))
)]
pub fn create_course_external() {}
@@ -78,15 +251,8 @@ pub fn create_course_external() {}
get,
path = "/api/external/v1/courses/{id}",
tag = "External",
params(
("id" = String, Path, description = "UUID del curso")
),
responses(
(status = 200, description = "Curso encontrado"),
(status = 401, description = "X-API-Key inválida o ausente"),
(status = 404, description = "Curso no encontrado")
),
security(("ApiKey" = []))
security(("ApiKey" = [])),
responses((status = 200, description = "Curso encontrado"))
)]
pub fn get_course_external() {}
@@ -94,14 +260,171 @@ pub fn get_course_external() {}
post,
path = "/api/external/v1/lessons/{id}/transcribe",
tag = "External",
params(
("id" = String, Path, description = "UUID de la lección")
),
responses(
(status = 202, description = "Transcripción encolada"),
(status = 401, description = "X-API-Key inválida o ausente"),
(status = 404, description = "Lección no encontrada")
),
security(("ApiKey" = []))
security(("ApiKey" = [])),
responses((status = 202, description = "Transcripcion encolada"))
)]
pub fn trigger_transcription_external() {}
protected_ok_path!(get_courses, get, "/courses", "Courses");
protected_ok_path!(create_course, post, "/courses", "Courses");
protected_ok_path!(get_course, get, "/courses/{id}", "Courses");
protected_ok_path!(update_course, put, "/courses/{id}", "Courses");
protected_ok_path!(delete_course, delete, "/courses/{id}", "Courses");
protected_ok_path!(publish_course, post, "/courses/{id}/publish", "Courses");
protected_ok_path!(get_course_outline, get, "/courses/{id}/outline", "Courses");
protected_ok_path!(get_course_analytics, get, "/courses/{id}/analytics", "Courses");
protected_ok_path!(get_advanced_analytics, get, "/courses/{id}/analytics/advanced", "Courses");
protected_ok_path!(get_course_team, get, "/courses/{id}/team", "Courses");
protected_ok_path!(add_team_member, post, "/courses/{id}/team", "Courses");
protected_ok_path!(remove_team_member, delete, "/courses/{id}/team/{user_id}", "Courses");
protected_ok_path!(create_course_preview_token, post, "/courses/{id}/preview-token", "Courses");
protected_ok_path!(get_modules, get, "/modules", "Courses");
protected_ok_path!(create_module, post, "/modules", "Courses");
protected_ok_path!(reorder_modules, post, "/modules/reorder", "Courses");
protected_ok_path!(update_module, put, "/modules/{id}", "Courses");
protected_ok_path!(delete_module, delete, "/modules/{id}", "Courses");
protected_ok_path!(get_lessons, get, "/lessons", "Courses");
protected_ok_path!(create_lesson, post, "/lessons", "Courses");
protected_ok_path!(reorder_lessons, post, "/lessons/reorder", "Courses");
protected_ok_path!(get_lesson, get, "/lessons/{id}", "Courses");
protected_ok_path!(update_lesson, put, "/lessons/{id}", "Courses");
protected_ok_path!(delete_lesson, delete, "/lessons/{id}", "Courses");
protected_ok_path!(process_transcription, post, "/lessons/{id}/transcribe", "Courses");
protected_ok_path!(get_lesson_vtt, get, "/lessons/{id}/vtt", "Courses");
protected_ok_path!(get_lesson_heatmap, get, "/lessons/{id}/heatmap", "Courses");
protected_ok_path!(summarize_lesson, post, "/lessons/{id}/summarize", "Courses");
protected_ok_path!(generate_quiz, post, "/lessons/{id}/generate-quiz", "Courses");
protected_ok_path!(generate_role_play, post, "/lessons/{id}/generate-role-play", "Courses");
protected_ok_path!(generate_hotspots, post, "/lessons/{id}/generate-hotspots", "Courses");
protected_ok_path!(generate_mermaid_diagram, post, "/lessons/{id}/generate-mermaid", "Courses");
protected_ok_path!(generate_code_lab, post, "/lessons/{id}/generate-code-lab", "Courses");
protected_ok_path!(generate_course, post, "/courses/generate", "Courses");
protected_ok_path!(export_course, get, "/courses/{id}/export", "Courses");
protected_ok_path!(import_course, post, "/courses/import", "Courses");
protected_ok_path!(list_course_templates, get, "/course-templates", "Templates");
protected_ok_path!(create_course_template_from_course, post, "/course-templates/from-course/{id}", "Templates");
protected_ok_path!(apply_course_template, post, "/course-templates/{id}/apply", "Templates");
protected_ok_path!(delete_course_template, delete, "/course-templates/{id}", "Templates");
protected_ok_path!(create_grading_category, post, "/grading", "Courses");
protected_ok_path!(delete_grading_category, delete, "/grading/{id}", "Courses");
protected_ok_path!(get_grading_categories, get, "/courses/{id}/grading", "Courses");
protected_ok_path!(get_tipo_nota, get, "/tipo-nota", "Courses");
protected_ok_path!(get_me, get, "/auth/me", "Auth");
protected_ok_path!(get_all_users, get, "/users", "Admin");
protected_ok_path!(admin_create_user, post, "/users", "Admin");
protected_ok_path!(update_user, put, "/users/{id}", "Admin");
protected_ok_path!(delete_user, delete, "/users/{id}", "Admin");
protected_ok_path!(get_audit_logs, get, "/audit-logs", "Admin");
protected_ok_path!(review_text, post, "/api/ai/review-text", "Admin");
protected_ok_path!(list_assets, get, "/api/assets", "Assets");
protected_ok_path!(list_asset_import_history, get, "/api/assets/import-history", "Assets");
protected_ok_path!(upload_asset, post, "/api/assets/upload", "Assets");
protected_ok_path!(import_assets_zip, post, "/api/assets/import-zip", "Assets");
protected_ok_path!(ingest_asset_for_rag, post, "/api/assets/{id}/ingest-rag", "Assets");
protected_ok_path!(delete_asset, delete, "/api/assets/{id}", "Assets");
protected_ok_path!(get_webhooks, get, "/webhooks", "Organization");
protected_ok_path!(create_webhook, post, "/webhooks", "Organization");
protected_ok_path!(delete_webhook, delete, "/webhooks/{id}", "Organization");
protected_ok_path!(get_background_tasks, get, "/tasks", "Admin");
protected_ok_path!(retry_task, post, "/tasks/{id}/retry", "Admin");
protected_ok_path!(cancel_task, delete, "/tasks/{id}", "Admin");
protected_ok_path!(get_organization, get, "/organization", "Organization");
protected_ok_path!(get_sso_config, get, "/organization/sso", "Organization");
protected_ok_path!(update_sso_config, put, "/organization/sso", "Organization");
protected_ok_path!(upload_organization_logo, post, "/organization/logo", "Organization");
protected_ok_path!(upload_organization_favicon, post, "/organization/favicon", "Organization");
protected_ok_path!(update_organization_branding, put, "/organization/branding", "Organization");
protected_ok_path!(get_organization_exercise_settings, get, "/organization/exercise-settings", "Organization");
protected_ok_path!(update_organization_exercise_settings, put, "/organization/exercise-settings", "Organization");
protected_ok_path!(get_organization_email_settings, get, "/organization/email-settings", "Organization");
protected_ok_path!(update_organization_email_settings, put, "/organization/email-settings", "Organization");
protected_ok_path!(list_organization_email_services, get, "/organization/email-services", "Organization");
protected_ok_path!(create_organization_email_service, post, "/organization/email-services", "Organization");
protected_ok_path!(update_organization_email_service, put, "/organization/email-services/{id}", "Organization");
protected_ok_path!(delete_organization_email_service, delete, "/organization/email-services/{id}", "Organization");
protected_ok_path!(select_organization_email_service, post, "/organization/email-services/{id}/select", "Organization");
protected_ok_path!(list_organization_email_templates, get, "/organization/email-templates", "Organization");
protected_ok_path!(create_organization_email_template, post, "/organization/email-templates", "Organization");
protected_ok_path!(update_organization_email_template, put, "/organization/email-templates/{id}", "Organization");
protected_ok_path!(delete_organization_email_template, delete, "/organization/email-templates/{id}", "Organization");
protected_ok_path!(list_library_blocks, get, "/library/blocks", "Assets");
protected_ok_path!(create_library_block, post, "/library/blocks", "Assets");
protected_ok_path!(get_library_block, get, "/library/blocks/{id}", "Assets");
protected_ok_path!(update_library_block, put, "/library/blocks/{id}", "Assets");
protected_ok_path!(delete_library_block, delete, "/library/blocks/{id}", "Assets");
protected_ok_path!(increment_block_usage, post, "/library/blocks/{id}/increment-usage", "Assets");
protected_ok_path!(list_course_rubrics, get, "/courses/{id}/rubrics", "Rubrics");
protected_ok_path!(create_rubric, post, "/courses/{id}/rubrics", "Rubrics");
protected_ok_path!(get_rubric_with_details, get, "/rubrics/{id}", "Rubrics");
protected_ok_path!(update_rubric, put, "/rubrics/{id}", "Rubrics");
protected_ok_path!(delete_rubric, delete, "/rubrics/{id}", "Rubrics");
protected_ok_path!(create_criterion, post, "/rubrics/{id}/criteria", "Rubrics");
protected_ok_path!(update_criterion, put, "/criteria/{id}", "Rubrics");
protected_ok_path!(delete_criterion, delete, "/criteria/{id}", "Rubrics");
protected_ok_path!(create_level, post, "/criteria/{id}/levels", "Rubrics");
protected_ok_path!(update_level, put, "/levels/{id}", "Rubrics");
protected_ok_path!(delete_level, delete, "/levels/{id}", "Rubrics");
protected_ok_path!(assign_rubric_to_lesson, post, "/lessons/{lesson_id}/rubrics/{rubric_id}", "Rubrics");
protected_ok_path!(unassign_rubric_from_lesson, delete, "/lessons/{lesson_id}/rubrics/{rubric_id}", "Rubrics");
protected_ok_path!(get_lesson_rubrics, get, "/lessons/{id}/rubrics", "Rubrics");
protected_ok_path!(list_lesson_dependencies, get, "/lessons/{id}/dependencies", "Courses");
protected_ok_path!(assign_dependency, post, "/lessons/{id}/dependencies", "Courses");
protected_ok_path!(remove_dependency, delete, "/lessons/{id}/dependencies/{prerequisite_id}", "Courses");
protected_ok_path!(list_test_templates, get, "/test-templates", "Templates");
protected_ok_path!(create_test_template, post, "/test-templates", "Templates");
protected_ok_path!(get_test_template, get, "/test-templates/{id}", "Templates");
protected_ok_path!(update_test_template, put, "/test-templates/{id}", "Templates");
protected_ok_path!(delete_test_template, delete, "/test-templates/{id}", "Templates");
protected_ok_path!(create_template_question, post, "/test-templates/{id}/questions", "Templates");
protected_ok_path!(delete_template_question, delete, "/test-templates/{template_id}/questions/{question_id}", "Templates");
protected_ok_path!(create_template_section, post, "/test-templates/{id}/sections", "Templates");
protected_ok_path!(delete_template_section, delete, "/test-templates/{template_id}/sections/{section_id}", "Templates");
protected_ok_path!(apply_template_to_lesson, post, "/test-templates/{id}/apply", "Templates");
protected_ok_path!(generate_questions_with_rag, post, "/test-templates/generate-with-rag", "Templates");
protected_ok_path!(list_questions, get, "/question-bank", "QuestionBank");
protected_ok_path!(create_question, post, "/question-bank", "QuestionBank");
protected_ok_path!(get_question, get, "/question-bank/{id}", "QuestionBank");
protected_ok_path!(update_question, put, "/question-bank/{id}", "QuestionBank");
protected_ok_path!(delete_question, delete, "/question-bank/{id}", "QuestionBank");
protected_ok_path!(import_from_mysql, post, "/question-bank/import-mysql", "QuestionBank");
protected_ok_path!(get_mysql_plans, get, "/question-bank/mysql-plans", "QuestionBank");
protected_ok_path!(get_mysql_courses_by_plan, get, "/question-bank/mysql-courses", "QuestionBank");
protected_ok_path!(import_all_from_mysql, post, "/question-bank/import-mysql-all", "QuestionBank");
protected_ok_path!(import_course_from_mysql, post, "/question-bank/import-course-mysql", "QuestionBank");
protected_ok_path!(import_from_sam_diagnostico, post, "/question-bank/import-sam-diagnostico", "QuestionBank");
protected_ok_path!(ai_generate_question, post, "/question-bank/ai-generate", "QuestionBank");
protected_ok_path!(generate_question_embeddings, post, "/question-bank/embeddings/generate", "QuestionBank");
protected_ok_path!(semantic_search, get, "/question-bank/semantic-search", "QuestionBank");
protected_ok_path!(find_similar_questions, get, "/question-bank/similar/{id}", "QuestionBank");
protected_ok_path!(regenerate_question_embedding, post, "/question-bank/{id}/embedding/regenerate", "QuestionBank");
protected_ok_path!(sync_all_sam, post, "/sam/sync-all", "SAM");
protected_ok_path!(sync_sam_students, post, "/sam/sync-students", "SAM");
protected_ok_path!(sync_sam_assignments, post, "/sam/sync-assignments", "SAM");
protected_ok_path!(list_sam_students, get, "/sam/students", "SAM");
protected_ok_path!(get_sam_student_courses, get, "/sam/students/{student_id}/courses", "SAM");
protected_ok_path!(get_token_usage, get, "/admin/token-usage", "Admin");
protected_ok_path!(get_ai_usage_global, get, "/admin/ai-usage/global", "Admin");
protected_ok_path!(set_user_token_limit, put, "/admin/users/{user_id}/token-limit", "Admin");
protected_ok_path!(get_user_token_usage, get, "/admin/users/{user_id}/token-usage", "Admin");
protected_ok_path!(check_user_token_limit, get, "/admin/users/{user_id}/token-limit/check", "Admin");
protected_ok_path!(list_plugins, get, "/plugins", "Plugins");
protected_ok_path!(create_plugin, post, "/plugins", "Plugins");
protected_ok_path!(list_enabled_plugins, get, "/plugins/enabled", "Plugins");
protected_ok_path!(update_plugin, put, "/plugins/{id}", "Plugins");
protected_ok_path!(delete_plugin, delete, "/plugins/{id}", "Plugins");
@@ -0,0 +1,19 @@
-- Fase 41-B: Anotaciones Privadas en Lecciones
CREATE TABLE IF NOT EXISTS lesson_annotations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
lesson_id UUID NOT NULL,
organization_id UUID NOT NULL,
course_id UUID NOT NULL,
content TEXT NOT NULL,
-- JSON libre: { "type": "timestamp", "value": 42.5 } | { "type": "scroll", "value": 0.35 }
position_data JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_lesson_annotations_user_lesson
ON lesson_annotations (user_id, lesson_id);
CREATE INDEX IF NOT EXISTS idx_lesson_annotations_user_org
ON lesson_annotations (user_id, organization_id);
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS mentorship_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
course_id UUID NOT NULL,
mentor_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
student_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
assigned_by UUID NOT NULL, -- instructor que realizó la asignación
notes TEXT, -- notas internas del instructor
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (course_id, mentor_id, student_id)
);
CREATE INDEX IF NOT EXISTS idx_mentorship_course ON mentorship_assignments (course_id, organization_id);
CREATE INDEX IF NOT EXISTS idx_mentorship_mentor ON mentorship_assignments (mentor_id, organization_id);
CREATE INDEX IF NOT EXISTS idx_mentorship_student ON mentorship_assignments (student_id, organization_id);
@@ -0,0 +1,31 @@
-- Fase 41-F: Evaluación entre Pares Mejorada
-- Configuración de peer review por lección
CREATE TABLE IF NOT EXISTS peer_review_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lesson_id UUID NOT NULL UNIQUE REFERENCES lessons(id) ON DELETE CASCADE,
organization_id UUID NOT NULL,
required_reviews INT NOT NULL DEFAULT 2, -- cuántas revisiones necesita cada entrega
peer_weight INT NOT NULL DEFAULT 70, -- 0-100: peso del promedio de pares en la nota final
instructor_weight INT NOT NULL DEFAULT 30, -- 0-100: peso de la calificación del instructor
rubric_id UUID, -- rúbrica externa (CMS) para orientar la evaluación
auto_assign BOOLEAN NOT NULL DEFAULT true, -- asignación automática al entregar
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT peer_weights_sum CHECK (peer_weight + instructor_weight = 100)
);
CREATE INDEX IF NOT EXISTS idx_peer_review_settings_lesson ON peer_review_settings (lesson_id);
-- Calificación del instructor sobre una entrega
ALTER TABLE peer_reviews
ADD COLUMN IF NOT EXISTS is_instructor_review BOOLEAN NOT NULL DEFAULT false;
-- Calificación final calculada (desnormalizada para eficiencia)
ALTER TABLE course_submissions
ADD COLUMN IF NOT EXISTS final_score FLOAT, -- NULL mientras no esté completo
ADD COLUMN IF NOT EXISTS review_count INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending';
-- status: 'pending' | 'under_review' | 'graded'
CREATE INDEX IF NOT EXISTS idx_course_submissions_status ON course_submissions (lesson_id, status);
+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,
))