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
+18
View File
@@ -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);
+96 -3
View File
@@ -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