feat: add sequential progression and video watch lesson completion

Gate student content by completion order, track per-video watch progress
with seek enforcement, and auto-complete lessons at the configured
threshold. Show ring progress in the lesson header, keep watch progress
on rewatch, and sync progression locks client-side so the next item
unlocks after completion.
This commit is contained in:
Rotimi Best
2026-06-11 14:48:34 +01:00
parent c8154409f5
commit 52d9bf18c3
94 changed files with 3499 additions and 564 deletions
+36
View File
@@ -0,0 +1,36 @@
import { ContentType } from '@cio/utils/constants';
import { assertStudentCanAccessContent } from '@cio/core/services/course/progression';
import { getCourseById, getCourseProgress } from '@cio/db/queries/course/course';
import { getCourseContentItems } from '@cio/db/queries/course/content';
const DEFAULT_CONTENT_GROUPING = true;
export async function assertEnrolledStudentContentAccess(params: {
courseId: string;
profileId: string;
contentId: string;
type: ContentType.Lesson | ContentType.Exercise;
}): Promise<void> {
const [courseRow, progress, contentItems] = await Promise.all([
getCourseById(params.courseId),
getCourseProgress(params.courseId, params.profileId),
getCourseContentItems(params.courseId, params.profileId)
]);
const course = courseRow[0];
if (!course) return;
const isContentGroupingEnabled = course.metadata?.isContentGroupingEnabled ?? DEFAULT_CONTENT_GROUPING;
const progressionMode = course.metadata?.progressionMode ?? 'free';
await assertStudentCanAccessContent({
courseId: params.courseId,
profileId: params.profileId,
roleId: progress.roleId,
contentId: params.contentId,
type: params.type,
progressionMode,
contentRows: contentItems,
isContentGroupingEnabled
});
}
+5
View File
@@ -18,6 +18,11 @@ export type CourseContentItem = {
documentsCount: number | null;
questionCount: number | null;
dueBy: string | null;
accessible?: boolean;
lockReason?: 'teacher_locked' | 'progression_blocked' | null;
completionPolicy?: string | null;
videoWatchThreshold?: number | null;
passThreshold?: number | null;
};
export type CourseContentSection = {
+29 -4
View File
@@ -44,10 +44,36 @@ import { QUESTION_TYPE_ID_TO_KEY } from '@cio/question-types';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from '@cio/core/utils/s3';
import { syncComplianceProgressFromSubmission } from '@api/services/course/compliance';
import { evaluateCourseCertification } from '@api/services/course/completion';
import { isExercisePassedForMember } from '@cio/db/queries/course/progression';
type SubmissionGradingState = 'queued' | 'processing' | 'awaiting_manual' | 'completed' | 'failed';
type SubmissionOverallStatus = 'auto_graded' | 'manual_required' | 'hybrid';
async function triggerCertificationIfExerciseComplete(
courseId: string,
exerciseId: string,
groupMemberId: string
): Promise<void> {
const exercise = await getExerciseById(exerciseId);
if (!exercise) return;
const profile = await getProfileByGroupMemberId(groupMemberId);
if (!profile?.id) return;
const policy = exercise.completionPolicy ?? 'submitted';
if (policy === 'passed') {
const threshold = exercise.passThreshold ?? 100;
const passed = await isExercisePassedForMember(exerciseId, groupMemberId, threshold);
if (!passed) return;
}
void evaluateCourseCertification(courseId, profile.id).catch((certError) => {
console.error('Failed to evaluate course certification after exercise submission:', certError);
});
}
const LEGACY_BOARD_STATUS_LABELS: Record<number, string> = {
1: 'Submitted',
2: 'In Progress',
@@ -638,10 +664,7 @@ export async function createSubmissionService(
});
}
const shouldAutoGrade =
(course?.type === 'SELF_PACED' || course?.type === 'COMPLIANCE') &&
overallStatus === 'auto_graded' &&
exerciseWithRelations.questions.length > 0;
const shouldAutoGrade = overallStatus === 'auto_graded' && exerciseWithRelations.questions.length > 0;
if (shouldAutoGrade) {
const answeredIds = new Set(answerByQuestionId.keys());
@@ -701,6 +724,7 @@ export async function createSubmissionService(
});
await syncComplianceProgressFromSubmission(courseId, submittedBy);
await triggerCertificationIfExerciseComplete(courseId, exerciseId, submittedBy);
const enrichedAnswers =
answersWithPoints.length > 0 ? await enrichFileUploadAnswersArray(answersWithPoints) : answersWithPoints;
@@ -713,6 +737,7 @@ export async function createSubmissionService(
});
await syncComplianceProgressFromSubmission(courseId, submittedBy);
await triggerCertificationIfExerciseComplete(courseId, exerciseId, submittedBy);
return submission;
} catch (error) {