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:
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user