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
+21 -2
View File
@@ -32,6 +32,7 @@ import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-ex
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
import { getProfileById } from '@cio/db/queries/auth';
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
import { annotateCourseContentWithProgression } from './progression';
import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type CourseContent } from './utils';
import { guardCourseTypeTransition } from './public-course-guard';
@@ -136,13 +137,31 @@ export async function getCourse(courseId?: string, slug?: string, profileId?: st
}
const isContentGroupingEnabled = course.metadata?.isContentGroupingEnabled ?? DEFAULT_CONTENT_GROUPING;
const content = buildCourseContent(course.contentItems, isContentGroupingEnabled);
const progressionMode = course.metadata?.progressionMode ?? 'free';
const roleId = profileId
? (course.group?.members?.find((member) => member.profileId === profileId)?.roleId ?? null)
: null;
const content = profileId
? await annotateCourseContentWithProgression({
courseId: course.id,
profileId,
roleId,
progressionMode,
contentRows: course.contentItems,
isContentGroupingEnabled
})
: buildCourseContent(course.contentItems, isContentGroupingEnabled);
const { contentItems, org: courseOrg, ...rest } = course;
const base = {
...rest,
content
content,
metadata: {
...course.metadata,
progressionMode
}
};
if (courseOrg) {
@@ -0,0 +1,277 @@
import { ContentType } from '@cio/utils/constants';
import { ROLE } from '@cio/utils/constants';
import { AppError, ErrorCodes } from '@cio/utils/errors';
import type { CourseContentItemRow } from '@cio/db/queries/course/content';
import {
getCompletedLessonIdsForProfile,
getExerciseProgressionPolicies,
getLessonProgressionPolicies,
getPassedExerciseIdsForMember,
getSubmittedExerciseIdsForMember,
type ExerciseProgressionPolicy,
type LessonProgressionPolicy
} from '@cio/db/queries/course/progression';
import { getGroupMemberIdByCourseAndProfile } from '@cio/db/queries/group';
import { buildCourseContent, type CourseContent, type CourseContentItem } from './utils';
export type ProgressionLockReason = 'teacher_locked' | 'progression_blocked';
export type AnnotatedCourseContentItem = CourseContentItem & {
accessible?: boolean;
lockReason?: ProgressionLockReason | null;
completionPolicy?: string | null;
videoWatchThreshold?: number | null;
passThreshold?: number | null;
};
export type AnnotatedCourseContent = {
grouped: boolean;
sections: Array<{
id: string;
title: string;
order: number | null;
items: AnnotatedCourseContentItem[];
}>;
items: AnnotatedCourseContentItem[];
};
function sortNavigableItems(items: CourseContentItem[]): CourseContentItem[] {
return [...items].sort((left, right) => {
const orderDiff = (left.order ?? 0) - (right.order ?? 0);
if (orderDiff !== 0) return orderDiff;
const typeDiff = (left.type === ContentType.Lesson ? 0 : 1) - (right.type === ContentType.Lesson ? 0 : 1);
if (typeDiff !== 0) return typeDiff;
return new Date(left.createdAt || 0).getTime() - new Date(right.createdAt || 0).getTime();
});
}
function flattenNavigableItems(content: CourseContent): CourseContentItem[] {
const items = content.grouped ? content.sections.flatMap((section) => section.items) : content.items;
return sortNavigableItems(
items.filter((item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise)
);
}
function lessonBlocksProgression(policy: string | undefined): boolean {
return policy !== 'none';
}
function exerciseBlocksProgression(): boolean {
return true;
}
function isItemProgressComplete(params: {
item: CourseContentItem;
lessonPolicy?: LessonProgressionPolicy;
exercisePolicy?: ExerciseProgressionPolicy;
completedLessonIds: Set<string>;
submittedExerciseIds: Set<string>;
passedExerciseIds: Set<string>;
}): boolean {
const { item, lessonPolicy, exercisePolicy, completedLessonIds, submittedExerciseIds, passedExerciseIds } = params;
if (item.type === ContentType.Lesson) {
return completedLessonIds.has(item.id);
}
if (item.type === ContentType.Exercise) {
const policy = exercisePolicy?.completionPolicy ?? 'submitted';
if (policy === 'passed') {
return passedExerciseIds.has(item.id);
}
return submittedExerciseIds.has(item.id) || Boolean(item.isComplete);
}
return true;
}
function annotateNavigableAccess(params: {
navigableItems: CourseContentItem[];
lessonPolicyById: Map<string, LessonProgressionPolicy>;
exercisePolicyById: Map<string, ExerciseProgressionPolicy>;
progressionMode: 'free' | 'sequential';
completedLessonIds: Set<string>;
submittedExerciseIds: Set<string>;
passedExerciseIds: Set<string>;
}): Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }> {
const accessById = new Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>();
let priorBlockingComplete = true;
for (const item of params.navigableItems) {
const lessonPolicy = params.lessonPolicyById.get(item.id);
const exercisePolicy = params.exercisePolicyById.get(item.id);
const teacherLocked = !(item.isUnlocked ?? false);
const progressionLocked = params.progressionMode === 'sequential' && !priorBlockingComplete;
let accessible = !teacherLocked && !progressionLocked;
let lockReason: ProgressionLockReason | null = null;
if (teacherLocked) {
accessible = false;
lockReason = 'teacher_locked';
} else if (progressionLocked) {
accessible = false;
lockReason = 'progression_blocked';
}
accessById.set(item.id, { accessible, lockReason });
const complete = isItemProgressComplete({
item,
lessonPolicy,
exercisePolicy,
completedLessonIds: params.completedLessonIds,
submittedExerciseIds: params.submittedExerciseIds,
passedExerciseIds: params.passedExerciseIds
});
const blocks =
item.type === ContentType.Lesson
? lessonBlocksProgression(lessonPolicy?.completionPolicy ?? 'manual')
: exerciseBlocksProgression();
if (blocks && !complete) {
priorBlockingComplete = false;
}
}
return accessById;
}
function applyAccessToContent(
content: CourseContent,
accessById: Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>,
lessonPolicyById: Map<string, LessonProgressionPolicy>,
exercisePolicyById: Map<string, ExerciseProgressionPolicy>
): AnnotatedCourseContent {
const annotateItem = (item: CourseContentItem): AnnotatedCourseContentItem => {
const access = accessById.get(item.id);
const lessonPolicy = lessonPolicyById.get(item.id);
const exercisePolicy = exercisePolicyById.get(item.id);
return {
...item,
accessible: access?.accessible ?? true,
lockReason: access?.lockReason ?? null,
completionPolicy: lessonPolicy?.completionPolicy ?? exercisePolicy?.completionPolicy ?? null,
videoWatchThreshold: lessonPolicy?.videoWatchThreshold ?? null,
passThreshold: exercisePolicy?.passThreshold ?? null
};
};
if (!content.grouped) {
return {
grouped: false,
sections: [],
items: content.items.map(annotateItem)
};
}
return {
grouped: true,
sections: content.sections.map((section) => ({
...section,
items: section.items.map(annotateItem)
})),
items: []
};
}
export async function annotateCourseContentWithProgression(params: {
courseId: string;
profileId: string;
roleId: number | null;
progressionMode?: 'free' | 'sequential';
contentRows: CourseContentItemRow[];
isContentGroupingEnabled: boolean;
}): Promise<AnnotatedCourseContent> {
const content = buildCourseContent(params.contentRows, params.isContentGroupingEnabled);
if (params.roleId !== ROLE.STUDENT) {
const [lessonPolicies, exercisePolicies] = await Promise.all([
getLessonProgressionPolicies(params.courseId),
getExerciseProgressionPolicies(params.courseId)
]);
const lessonPolicyById = new Map(lessonPolicies.map((policy) => [policy.id, policy]));
const exercisePolicyById = new Map(exercisePolicies.map((policy) => [policy.id, policy]));
const accessById = new Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>();
for (const item of flattenNavigableItems(content)) {
accessById.set(item.id, { accessible: true, lockReason: null });
}
return applyAccessToContent(content, accessById, lessonPolicyById, exercisePolicyById);
}
const progressionMode = params.progressionMode ?? 'free';
const groupMemberId = await getGroupMemberIdByCourseAndProfile(params.courseId, params.profileId);
const [lessonPolicies, exercisePolicies, completedLessonIds, submittedExerciseIds] = await Promise.all([
getLessonProgressionPolicies(params.courseId),
getExerciseProgressionPolicies(params.courseId),
getCompletedLessonIdsForProfile(params.courseId, params.profileId),
groupMemberId
? getSubmittedExerciseIdsForMember(params.courseId, groupMemberId)
: Promise.resolve(new Set<string>())
]);
const passedExerciseIds = groupMemberId
? await getPassedExerciseIdsForMember(params.courseId, groupMemberId, exercisePolicies)
: new Set<string>();
const lessonPolicyById = new Map(lessonPolicies.map((policy) => [policy.id, policy]));
const exercisePolicyById = new Map(exercisePolicies.map((policy) => [policy.id, policy]));
const accessById = annotateNavigableAccess({
navigableItems: flattenNavigableItems(content),
lessonPolicyById,
exercisePolicyById,
progressionMode,
completedLessonIds,
submittedExerciseIds,
passedExerciseIds
});
return applyAccessToContent(content, accessById, lessonPolicyById, exercisePolicyById);
}
export async function assertStudentCanAccessContent(params: {
courseId: string;
profileId: string;
roleId: number | null;
contentId: string;
type: ContentType.Lesson | ContentType.Exercise;
progressionMode?: 'free' | 'sequential';
contentRows: CourseContentItemRow[];
isContentGroupingEnabled: boolean;
}): Promise<void> {
if (params.roleId !== ROLE.STUDENT) {
return;
}
const annotated = await annotateCourseContentWithProgression({
courseId: params.courseId,
profileId: params.profileId,
roleId: params.roleId,
progressionMode: params.progressionMode,
contentRows: params.contentRows,
isContentGroupingEnabled: params.isContentGroupingEnabled
});
const items = annotated.grouped ? annotated.sections.flatMap((section) => section.items) : annotated.items;
const target = items.find((item) => item.id === params.contentId && item.type === params.type);
if (!target) {
throw new AppError('Content not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
if (target.accessible === false) {
throw new AppError('Content is locked', ErrorCodes.VALIDATION_ERROR, 403);
}
}
@@ -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 = {
+248
View File
@@ -12,14 +12,19 @@ import {
getLessonCommentsByLessonIdPaginated,
getLessonCompletion,
getLessonVersionHistory,
getLessonVideoProgress,
getLessonVideoProgressForLesson,
getLessonsByCourseId,
updateLesson,
updateLessonComment,
upsertLessonCompletion,
upsertLessonVideoProgress,
type LessonById
} from '@cio/db/queries/lesson';
import type { TUpdateLessonWatchProgress } from '@cio/utils/validation/lesson';
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
import { enrichLessonWithPresignedUrls } from '../../utils/lesson-media';
import { resolveWatchEnforcedAssetIds } from '../../utils/lesson-watch-enforcement';
import { resolveItemSlug } from '../course/slug';
/**
@@ -372,6 +377,19 @@ export async function getLessonCompletionService(lessonId: string, profileId: st
*/
export async function upsertLessonCompletionService(lessonId: string, profileId: string, isComplete: boolean) {
try {
const lesson = await getLessonById(lessonId);
if (!lesson) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
if (lesson.completionPolicy === 'video_watch' && isComplete) {
throw new AppError(
'This lesson completes automatically when the video watch requirement is met',
ErrorCodes.VALIDATION_ERROR,
400
);
}
const completionData: TNewLessonCompletion = {
lessonId,
profileId,
@@ -380,6 +398,10 @@ export async function upsertLessonCompletionService(lessonId: string, profileId:
return await upsertLessonCompletion(completionData);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update lesson completion',
ErrorCodes.INTERNAL_ERROR,
@@ -388,6 +410,232 @@ export async function upsertLessonCompletionService(lessonId: string, profileId:
}
}
const WATCH_PROGRESS_WALL_CLOCK_TOLERANCE = 1.5;
export type LessonWatchProgressAssetResult = {
assetId: string;
lastPositionSeconds: number;
watchedSeconds: number;
furthestSeconds: number;
durationSeconds: number | null;
watchedPercent: number;
isComplete: boolean;
};
export type LessonWatchProgressResult = {
lastPositionSeconds: number;
watchedSeconds: number;
furthestSeconds: number;
durationSeconds: number | null;
isComplete: boolean;
didJustComplete: boolean;
watchedPercent: number;
videosComplete: number;
videosRequired: number;
assets: LessonWatchProgressAssetResult[];
};
function getWatchedPercent(watchedSeconds: number, durationSeconds: number | null | undefined): number {
if (!durationSeconds || durationSeconds <= 0) return 0;
return Math.min(100, Math.round((watchedSeconds / durationSeconds) * 100));
}
function buildAggregateWatchProgress(
rows: Awaited<ReturnType<typeof getLessonVideoProgressForLesson>>,
requiredAssetIds: string[]
): LessonWatchProgressResult | null {
if (requiredAssetIds.length === 0) return null;
const rowByAssetId = new Map(rows.map((row) => [row.assetId, row]));
const assets: LessonWatchProgressAssetResult[] = requiredAssetIds.map((assetId) => {
const row = rowByAssetId.get(assetId);
const isAssetComplete = row?.isComplete ?? false;
return {
assetId,
lastPositionSeconds: row?.lastPositionSeconds ?? 0,
watchedSeconds: row?.watchedSeconds ?? 0,
furthestSeconds: row?.furthestSeconds ?? 0,
durationSeconds: row?.durationSeconds ?? null,
watchedPercent: isAssetComplete ? 100 : getWatchedPercent(row?.watchedSeconds ?? 0, row?.durationSeconds),
isComplete: isAssetComplete
};
});
const videosComplete = assets.filter((asset) => asset.isComplete).length;
const videosRequired = requiredAssetIds.length;
const isComplete = videosRequired > 0 && videosComplete === videosRequired;
const watchedPercent = isComplete
? 100
: assets.length > 0
? Math.min(...assets.map((asset) => asset.watchedPercent))
: 0;
const primaryAsset = assets[0];
return {
lastPositionSeconds: primaryAsset?.lastPositionSeconds ?? 0,
watchedSeconds: primaryAsset?.watchedSeconds ?? 0,
furthestSeconds: primaryAsset?.furthestSeconds ?? 0,
durationSeconds: primaryAsset?.durationSeconds ?? null,
isComplete,
didJustComplete: false,
watchedPercent,
videosComplete,
videosRequired,
assets
};
}
export async function getLessonWatchProgressService(
lessonId: string,
profileId: string
): Promise<LessonWatchProgressResult | null> {
try {
const lesson = await getLessonById(lessonId);
if (!lesson || lesson.completionPolicy !== 'video_watch') return null;
const requiredAssetIds = resolveWatchEnforcedAssetIds(lesson.videos, lesson.completionPolicy);
if (requiredAssetIds.length === 0) return null;
const progressRows = await getLessonVideoProgressForLesson(lessonId, profileId);
const filteredRows = progressRows.filter((row) => requiredAssetIds.includes(row.assetId));
return buildAggregateWatchProgress(filteredRows, requiredAssetIds);
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to get lesson watch progress',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function updateLessonWatchProgressService(
lessonId: string,
profileId: string,
beat: TUpdateLessonWatchProgress
): Promise<LessonWatchProgressResult> {
try {
const lesson = await getLessonById(lessonId);
if (!lesson) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
if (lesson.completionPolicy !== 'video_watch') {
throw new AppError('This lesson does not require video watch tracking', ErrorCodes.VALIDATION_ERROR, 400);
}
if (!beat.assetId) {
throw new AppError('Video asset is required for watch progress', ErrorCodes.VALIDATION_ERROR, 400);
}
const requiredAssetIds = resolveWatchEnforcedAssetIds(lesson.videos, lesson.completionPolicy);
if (!requiredAssetIds.includes(beat.assetId)) {
throw new AppError('This video is not configured for watch enforcement', ErrorCodes.VALIDATION_ERROR, 400);
}
const existing = await getLessonVideoProgress(lessonId, profileId, beat.assetId);
const threshold = lesson.videoWatchThreshold ?? 95;
const now = Date.now();
if (existing?.isComplete) {
const aggregate = buildAggregateWatchProgress(
await getLessonVideoProgressForLesson(lessonId, profileId),
requiredAssetIds
);
return {
...(aggregate ?? {
lastPositionSeconds: existing.lastPositionSeconds ?? beat.positionSeconds,
watchedSeconds: existing.watchedSeconds ?? 0,
furthestSeconds: existing.furthestSeconds ?? 0,
durationSeconds: existing.durationSeconds ?? beat.durationSeconds,
isComplete: true,
didJustComplete: false,
watchedPercent: 100,
videosComplete: requiredAssetIds.length,
videosRequired: requiredAssetIds.length,
assets: []
}),
didJustComplete: false
};
}
const positionSeconds = Math.floor(beat.positionSeconds);
if (existing?.updatedAt) {
const elapsedSeconds = (now - new Date(existing.updatedAt).getTime()) / 1000;
const maxAllowedDelta = Math.max(5, elapsedSeconds * WATCH_PROGRESS_WALL_CLOCK_TOLERANCE);
if (beat.playedDeltaSeconds > maxAllowedDelta) {
throw new AppError('Invalid watch progress update', ErrorCodes.VALIDATION_ERROR, 400);
}
}
const previousFurthest = existing?.furthestSeconds ?? 0;
const playedDeltaSeconds = Math.max(0, Math.round(beat.playedDeltaSeconds));
const reachedEnd = positionSeconds >= beat.durationSeconds - 1;
let watchedSeconds = Math.min(beat.durationSeconds, (existing?.watchedSeconds ?? 0) + playedDeltaSeconds);
if (reachedEnd) {
watchedSeconds = beat.durationSeconds;
}
const furthestSeconds = Math.max(previousFurthest, positionSeconds, reachedEnd ? beat.durationSeconds : 0);
const watchPercent = (watchedSeconds / beat.durationSeconds) * 100;
const assetComplete = watchPercent >= threshold;
const rowsBeforeUpdate = await getLessonVideoProgressForLesson(lessonId, profileId);
const priorAggregate = buildAggregateWatchProgress(rowsBeforeUpdate, requiredAssetIds);
const wasLessonComplete = priorAggregate?.isComplete ?? false;
await upsertLessonVideoProgress({
lessonId,
profileId,
assetId: beat.assetId,
durationSeconds: beat.durationSeconds,
watchedSeconds,
furthestSeconds,
lastPositionSeconds: reachedEnd ? beat.durationSeconds : positionSeconds,
isComplete: assetComplete,
completedAt: assetComplete ? new Date().toISOString() : null
});
const allRows = await getLessonVideoProgressForLesson(lessonId, profileId);
const aggregate = buildAggregateWatchProgress(allRows, requiredAssetIds);
const didJustComplete = (aggregate?.isComplete ?? false) && !wasLessonComplete;
if (didJustComplete) {
await upsertLessonCompletion({
lessonId,
profileId,
isComplete: true
});
}
if (!aggregate) {
throw new AppError('Failed to aggregate lesson watch progress', ErrorCodes.INTERNAL_ERROR, 500);
}
return {
...aggregate,
didJustComplete
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update lesson watch progress',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Gets lesson version history for a lesson and locale
* @param lessonId Lesson ID
@@ -0,0 +1,50 @@
type LessonVideo = {
type: string;
assetId?: string | null;
watchEnforced?: boolean | null;
link?: string;
metadata?: Record<string, unknown> | null;
};
function isHlsUploadVideo(video: LessonVideo): boolean {
if (video.type !== 'upload') return false;
const metadata = video.metadata;
if (metadata && typeof metadata === 'object' && metadata.hls === true) return true;
return typeof video.link === 'string' && video.link.includes('/hls/') && video.link.endsWith('.m3u8');
}
export function isEnforceableLessonVideo(video: LessonVideo): boolean {
return video.type === 'upload' && Boolean(video.assetId);
}
export function isEnforceableLessonVideoIncludingHls(video: LessonVideo): boolean {
return isEnforceableLessonVideo(video) || isHlsUploadVideo(video);
}
export function resolveWatchEnforcedAssetIds(
videos: LessonVideo[] | null | undefined,
completionPolicy: string | null | undefined
): string[] {
const lessonVideos = videos ?? [];
const flaggedAssetIds = lessonVideos
.filter((video) => video.watchEnforced && video.assetId && isEnforceableLessonVideoIncludingHls(video))
.map((video) => video.assetId as string);
if (flaggedAssetIds.length > 0) {
return Array.from(new Set(flaggedAssetIds));
}
if (completionPolicy !== 'video_watch') {
return [];
}
return Array.from(
new Set(
lessonVideos
.filter((video) => video.assetId && isEnforceableLessonVideoIncludingHls(video))
.map((video) => video.assetId as string)
)
);
}