diff --git a/services/cms-service/migrations/20231222000001_lesson_attempts.sql b/services/cms-service/migrations/20231222000001_lesson_attempts.sql new file mode 100644 index 0000000..94b8001 --- /dev/null +++ b/services/cms-service/migrations/20231222000001_lesson_attempts.sql @@ -0,0 +1,3 @@ +-- Add attempt limits and retry configuration to lessons +ALTER TABLE lessons ADD COLUMN max_attempts INTEGER; +ALTER TABLE lessons ADD COLUMN allow_retry BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 42f5114..4c7dbac 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -171,9 +171,12 @@ pub async fn create_lesson( let is_graded = payload.get("is_graded").and_then(|v| v.as_bool()).unwrap_or(false); let grading_category_id = payload.get("grading_category_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()); + let max_attempts = payload.get("max_attempts").and_then(|v| v.as_i64()).map(|v| v as i32); + let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool()).unwrap_or(true); let lesson = sqlx::query_as::<_, Lesson>( - "INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *" + "INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id, max_attempts, allow_retry) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *" ) .bind(module_id) .bind(title) @@ -184,6 +187,8 @@ pub async fn create_lesson( .bind(metadata) .bind(is_graded) .bind(grading_category_id) + .bind(max_attempts) + .bind(allow_retry) .fetch_one(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -261,6 +266,10 @@ pub async fn update_lesson( } }); + let max_attempts = payload.get("max_attempts").and_then(|v| v.as_i64()).map(|v| v as i32); + let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool()); + let metadata = payload.get("metadata"); + let updated_lesson = sqlx::query_as::<_, Lesson>( "UPDATE lessons SET title = COALESCE($1, title), @@ -268,8 +277,11 @@ pub async fn update_lesson( content_url = COALESCE($3, content_url), position = COALESCE($4, position), is_graded = COALESCE($5, is_graded), - grading_category_id = CASE WHEN $6 = 'SET_NULL' THEN NULL WHEN $7::UUID IS NOT NULL THEN $7 ELSE grading_category_id END - WHERE id = $8 RETURNING *" + grading_category_id = CASE WHEN $6 = 'SET_NULL' THEN NULL WHEN $7::UUID IS NOT NULL THEN $7 ELSE grading_category_id END, + metadata = COALESCE($8, metadata), + max_attempts = COALESCE($9, max_attempts), + allow_retry = COALESCE($10, allow_retry) + WHERE id = $11 RETURNING *" ) .bind(title) .bind(content_type) @@ -278,6 +290,9 @@ pub async fn update_lesson( .bind(is_graded) .bind(if payload.get("grading_category_id").map(|v| v.is_null()).unwrap_or(false) { "SET_NULL" } else { "" }) .bind(payload.get("grading_category_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok())) + .bind(metadata) + .bind(max_attempts) + .bind(allow_retry) .bind(id) .fetch_one(&pool) .await diff --git a/services/lms-service/migrations/20231222000001_lesson_attempts.sql b/services/lms-service/migrations/20231222000001_lesson_attempts.sql new file mode 100644 index 0000000..2aaf9ea --- /dev/null +++ b/services/lms-service/migrations/20231222000001_lesson_attempts.sql @@ -0,0 +1,6 @@ +-- Add attempt limits and retry configuration to lessons +ALTER TABLE lessons ADD COLUMN max_attempts INTEGER; +ALTER TABLE lessons ADD COLUMN allow_retry BOOLEAN NOT NULL DEFAULT TRUE; + +-- Track attempt count in user_grades +ALTER TABLE user_grades ADD COLUMN attempts_count INTEGER NOT NULL DEFAULT 1; diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 3ae4151..560d532 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -199,8 +199,8 @@ pub async fn ingest_course( for lesson in pub_module.lessons { sqlx::query( - "INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)" + "INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)" ) .bind(lesson.id) .bind(pub_module.module.id) @@ -213,6 +213,8 @@ pub async fn ingest_course( .bind(lesson.created_at) .bind(lesson.is_graded) .bind(lesson.grading_category_id) + .bind(lesson.max_attempts) + .bind(lesson.allow_retry) .execute(&mut *tx) .await .map_err(|e| { @@ -306,12 +308,43 @@ pub async fn submit_lesson_score( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { + // 1. Get lesson attempt rules + let max_attempts: Option> = sqlx::query_scalar("SELECT max_attempts FROM lessons WHERE id = $1") + .bind(payload.lesson_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if max_attempts.is_none() { + return Err((StatusCode::NOT_FOUND, "Lesson not found".into())); + } + let max_attempts = max_attempts.flatten(); + + // 2. Check existing grade/attempts + let existing_attempts: Option = sqlx::query_scalar("SELECT attempts_count FROM user_grades WHERE user_id = $1 AND lesson_id = $2") + .bind(payload.user_id) + .bind(payload.lesson_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if let Some(count) = existing_attempts { + if let Some(max) = max_attempts { + if count >= max { + tracing::warn!("User {} attempted to resubmit lesson {} but reached max_attempts ({})", payload.user_id, payload.lesson_id, max); + return Err((StatusCode::FORBIDDEN, "Maximum attempts reached for this assessment".into())); + } + } + } + + // 3. Upsert with increment let grade = sqlx::query_as::<_, common::models::UserGrade>( - "INSERT INTO user_grades (user_id, course_id, lesson_id, score, metadata) - VALUES ($1, $2, $3, $4, $5) + "INSERT INTO user_grades (user_id, course_id, lesson_id, score, metadata, attempts_count) + VALUES ($1, $2, $3, $4, $5, 1) ON CONFLICT (user_id, lesson_id) DO UPDATE SET score = EXCLUDED.score, metadata = EXCLUDED.metadata, + attempts_count = user_grades.attempts_count + 1, created_at = CURRENT_TIMESTAMP RETURNING *" ) diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 647c89f..8240181 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -34,6 +34,8 @@ pub struct Lesson { pub metadata: Option, pub grading_category_id: Option, pub is_graded: bool, + pub max_attempts: Option, + pub allow_retry: bool, pub position: i32, pub created_at: DateTime, } @@ -55,6 +57,7 @@ pub struct UserGrade { pub course_id: Uuid, pub lesson_id: Uuid, pub score: f32, // 0.0 to 1.0 + pub attempts_count: i32, pub metadata: Option, pub created_at: DateTime, } diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 3005c71..96d9a58 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { lmsApi, Lesson, Course, Module } from "@/lib/api"; import Link from "next/link"; import { ChevronLeft, ChevronRight, Menu, CheckCircle2 } from "lucide-react"; +import { useAuth } from "@/context/AuthContext"; import DescriptionPlayer from "@/components/blocks/DescriptionPlayer"; import MediaPlayer from "@/components/blocks/MediaPlayer"; @@ -18,6 +19,8 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les const [course, setCourse] = useState<(Course & { modules: Module[] }) | null>(null); const [loading, setLoading] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true); + const [userGrade, setUserGrade] = useState(null); + const { user } = useAuth(); useEffect(() => { const fetchAll = async () => { @@ -28,8 +31,14 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les ]); setLesson(lessonData); setCourse(courseData); + + if (user) { + const grades = await lmsApi.getUserGrades(user.id, params.id); + const currentGrade = grades.find((g: any) => g.lesson_id === params.lessonId); + setUserGrade(currentGrade || null); + } } catch (err) { - console.error(err); + console.error("Failed to load lesson data", err); } finally { setLoading(false); } @@ -116,16 +125,16 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les /> )} {block.type === 'quiz' && ( - + )} {block.type === 'fill-in-the-blanks' && ( - + )} {block.type === 'matching' && ( - + )} {block.type === 'ordering' && ( - + )} {block.type === 'short-answer' && ( )} @@ -146,41 +156,53 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les {lesson.is_graded && (
-
-
-

Complete & Submit

-

- This activity is graded. Make sure you've completed all blocks before submitting your work for evaluation. -

- - -
+ }} + className="btn-premium px-12 py-4 rounded-2xl shadow-blue-500/40 shadow-xl group/btn" + > + + {userGrade ? `SUBMIT ATTEMPT ${userGrade.attempts_count + 1}` : 'SUBMIT FOR GRADING'} + + + + {lesson.max_attempts && ( +

+ Attempt {userGrade ? userGrade.attempts_count : 0} of {lesson.max_attempts} used +

+ )} + + )}
)} {/* Footer Controls */} -