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:
@@ -36,6 +36,8 @@ import { courseMemberMiddleware } from '@api/middlewares/course-member';
|
||||
import { courseMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-member-or-automation-key';
|
||||
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||
import { createSubmissionService, listExerciseSubmissionsOverview } from '@api/services/submission';
|
||||
import { assertEnrolledStudentContentAccess } from '@api/services/course/access';
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const exerciseRouter = new Hono()
|
||||
@@ -95,6 +97,15 @@ export const exerciseRouter = new Hono()
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'get_course_exercise');
|
||||
}
|
||||
|
||||
if (user?.id && !automationKey) {
|
||||
await assertEnrolledStudentContentAccess({
|
||||
courseId: c.req.param('courseId')!,
|
||||
profileId: user.id,
|
||||
contentId: exerciseId,
|
||||
type: ContentType.Exercise
|
||||
});
|
||||
}
|
||||
|
||||
const exercise = await getExercise(exerciseId, undefined, user?.id);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
@@ -260,6 +271,13 @@ export const exerciseRouter = new Hono()
|
||||
return c.json({ success: false, error: 'User is not a member of this course' }, 403);
|
||||
}
|
||||
|
||||
await assertEnrolledStudentContentAccess({
|
||||
courseId,
|
||||
profileId: user.id,
|
||||
contentId: exerciseId,
|
||||
type: ContentType.Exercise
|
||||
});
|
||||
|
||||
const submission = await createSubmissionService(courseId, exerciseId, groupMemberId, answers);
|
||||
|
||||
return c.json({ success: true, data: submission }, 201);
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
ZLessonHistoryQuery,
|
||||
ZLessonListQuery,
|
||||
ZLessonReorder,
|
||||
ZLessonUpdate
|
||||
ZLessonUpdate,
|
||||
ZUpdateLessonWatchProgress
|
||||
} from '@cio/utils/validation/lesson';
|
||||
import {
|
||||
createLesson,
|
||||
@@ -21,12 +22,17 @@ import {
|
||||
getLessonCommentsPaginated,
|
||||
getLessonCompletionService,
|
||||
getLessonHistoryService,
|
||||
getLessonWatchProgressService,
|
||||
listLessons,
|
||||
reorderLessons,
|
||||
updateLessonCommentService,
|
||||
updateLessonService,
|
||||
updateLessonWatchProgressService,
|
||||
upsertLessonCompletionService
|
||||
} from '@api/services/lesson';
|
||||
import { assertEnrolledStudentContentAccess } from '@api/services/course/access';
|
||||
import { evaluateCourseCertification } from '@api/services/course/completion';
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { ZLessonDownloadContent } from '@cio/utils/validation/course';
|
||||
@@ -63,10 +69,23 @@ export const lessonRouter = new Hono()
|
||||
})
|
||||
.get('/:lessonId', authMiddleware, courseMemberMiddleware, zValidator('param', ZLessonGetParam), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const courseId = c.req.param('courseId')!;
|
||||
const { lessonId } = c.req.valid('param');
|
||||
const lesson = await getLesson(lessonId);
|
||||
|
||||
return c.json({ success: true, data: lesson }, 200);
|
||||
await assertEnrolledStudentContentAccess({
|
||||
courseId,
|
||||
profileId: user.id,
|
||||
contentId: lessonId,
|
||||
type: ContentType.Lesson
|
||||
});
|
||||
|
||||
const [lesson, watchProgress] = await Promise.all([
|
||||
getLesson(lessonId),
|
||||
getLessonWatchProgressService(lessonId, user.id)
|
||||
]);
|
||||
|
||||
return c.json({ success: true, data: { ...lesson, watchProgress } }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch lesson');
|
||||
}
|
||||
@@ -220,17 +239,91 @@ export const lessonRouter = new Hono()
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const courseId = c.req.param('courseId')!;
|
||||
const { lessonId } = c.req.valid('param');
|
||||
const { isComplete } = c.req.valid('json');
|
||||
|
||||
await assertEnrolledStudentContentAccess({
|
||||
courseId,
|
||||
profileId: user.id,
|
||||
contentId: lessonId,
|
||||
type: ContentType.Lesson
|
||||
});
|
||||
|
||||
const completion = await upsertLessonCompletionService(lessonId, user.id, isComplete);
|
||||
|
||||
if (isComplete) {
|
||||
void evaluateCourseCertification(courseId, user.id).catch((certError) => {
|
||||
console.error('Failed to evaluate course certification after lesson completion:', certError);
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: completion }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update lesson completion');
|
||||
}
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/:lessonId/watch-progress',
|
||||
authMiddleware,
|
||||
courseMemberMiddleware,
|
||||
zValidator('param', ZLessonGetParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const courseId = c.req.param('courseId')!;
|
||||
const { lessonId } = c.req.valid('param');
|
||||
|
||||
await assertEnrolledStudentContentAccess({
|
||||
courseId,
|
||||
profileId: user.id,
|
||||
contentId: lessonId,
|
||||
type: ContentType.Lesson
|
||||
});
|
||||
|
||||
const watchProgress = await getLessonWatchProgressService(lessonId, user.id);
|
||||
|
||||
return c.json({ success: true, data: watchProgress }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to get lesson watch progress');
|
||||
}
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/:lessonId/watch-progress',
|
||||
authMiddleware,
|
||||
courseMemberMiddleware,
|
||||
zValidator('param', ZLessonGetParam),
|
||||
zValidator('json', ZUpdateLessonWatchProgress),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const courseId = c.req.param('courseId')!;
|
||||
const { lessonId } = c.req.valid('param');
|
||||
const beat = c.req.valid('json');
|
||||
|
||||
await assertEnrolledStudentContentAccess({
|
||||
courseId,
|
||||
profileId: user.id,
|
||||
contentId: lessonId,
|
||||
type: ContentType.Lesson
|
||||
});
|
||||
|
||||
const watchProgress = await updateLessonWatchProgressService(lessonId, user.id, beat);
|
||||
|
||||
if (watchProgress.didJustComplete) {
|
||||
void evaluateCourseCertification(courseId, user.id).catch((certError) => {
|
||||
console.error('Failed to evaluate course certification after video watch:', certError);
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: watchProgress }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update lesson watch progress');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* GET /course/:courseId/lesson/:lessonId/history
|
||||
* Gets lesson version history for a lesson and locale
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -35,11 +35,15 @@ export function resolveCertificateDesign(stored: unknown): CertificateDesign {
|
||||
const signatories: CertificateDesign['signatories'] = [
|
||||
{
|
||||
name: storedSignatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name,
|
||||
role: storedSignatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role
|
||||
role: storedSignatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role,
|
||||
enabled: storedSignatories?.[0]?.enabled ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].enabled,
|
||||
signatureUrl: storedSignatories?.[0]?.signatureUrl
|
||||
},
|
||||
{
|
||||
name: storedSignatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name,
|
||||
role: storedSignatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role
|
||||
role: storedSignatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role,
|
||||
enabled: storedSignatories?.[1]?.enabled ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].enabled,
|
||||
signatureUrl: storedSignatories?.[1]?.signatureUrl
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user