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
+27 -7
View File
@@ -42,13 +42,33 @@ export async function getCourseContentItems(courseId: string, profileId?: string
const exerciseCompletionSql = profileId
? drizzleSql`COALESCE(
(
SELECT true
FROM submission
INNER JOIN groupmember ON groupmember.id = submission.submitted_by
WHERE submission.exercise_id = exercise.id AND groupmember.profile_id = ${profileId}
LIMIT 1
),
CASE
WHEN exercise.completion_policy = 'passed' THEN (
SELECT true
FROM submission
INNER JOIN groupmember ON groupmember.id = submission.submitted_by
WHERE submission.exercise_id = exercise.id
AND groupmember.profile_id = ${profileId}
AND submission.grading_state = 'completed'
AND (
SELECT CASE
WHEN COALESCE(SUM(question.points), 0) <= 0 THEN false
ELSE ROUND((submission.total::numeric / SUM(question.points)::numeric) * 100)
>= COALESCE(exercise.pass_threshold, 100)
END
FROM question
WHERE question.exercise_id = exercise.id
)
LIMIT 1
)
ELSE (
SELECT true
FROM submission
INNER JOIN groupmember ON groupmember.id = submission.submitted_by
WHERE submission.exercise_id = exercise.id AND groupmember.profile_id = ${profileId}
LIMIT 1
)
END,
false
)`
: drizzleSql`false`;
+1
View File
@@ -1,6 +1,7 @@
export * from './course';
export * from './compliance';
export * from './content';
export * from './progression';
export * from './content-batch';
export * from './people';
export * from './invite';
@@ -0,0 +1,156 @@
import * as schema from '@db/schema';
import { and, eq, or } from 'drizzle-orm';
import { db } from '@db/drizzle';
import { getExerciseTitleAndMaxPoints, getStudentSubmissionsForExercise } from './certification-exercise';
export type LessonProgressionPolicy = {
id: string;
completionPolicy: string;
videoWatchThreshold: number | null;
isUnlocked: boolean | null;
};
export type ExerciseProgressionPolicy = {
id: string;
completionPolicy: string;
passThreshold: number | null;
isUnlocked: boolean | null;
};
export async function getLessonProgressionPolicies(courseId: string): Promise<LessonProgressionPolicy[]> {
try {
return db
.select({
id: schema.lesson.id,
completionPolicy: schema.lesson.completionPolicy,
videoWatchThreshold: schema.lesson.videoWatchThreshold,
isUnlocked: schema.lesson.isUnlocked
})
.from(schema.lesson)
.where(eq(schema.lesson.courseId, courseId));
} catch (error) {
console.error('getLessonProgressionPolicies error:', error);
throw new Error(
`Failed to get lesson progression policies: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getExerciseProgressionPolicies(courseId: string): Promise<ExerciseProgressionPolicy[]> {
try {
return db
.select({
id: schema.exercise.id,
completionPolicy: schema.exercise.completionPolicy,
passThreshold: schema.exercise.passThreshold,
isUnlocked: schema.exercise.isUnlocked
})
.from(schema.exercise)
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)));
} catch (error) {
console.error('getExerciseProgressionPolicies error:', error);
throw new Error(
`Failed to get exercise progression policies: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
function percentFromTotal(total: number, maxPoints: number): number {
if (maxPoints <= 0) return 0;
return Math.round((total / maxPoints) * 100);
}
export async function isExercisePassedForMember(
exerciseId: string,
groupMemberId: string,
passThreshold: number
): Promise<boolean> {
try {
const { maxPoints } = await getExerciseTitleAndMaxPoints(exerciseId);
if (maxPoints <= 0) return false;
const submissions = await getStudentSubmissionsForExercise(groupMemberId, exerciseId);
const completed = submissions.filter((submission) => submission.gradingState === 'completed');
if (completed.length === 0) return false;
let bestPercent = 0;
for (const submission of completed) {
const total = Number(submission.total ?? 0);
const percent = percentFromTotal(total, maxPoints);
if (percent > bestPercent) bestPercent = percent;
}
return bestPercent >= passThreshold;
} catch (error) {
console.error('isExercisePassedForMember error:', error);
throw new Error(
`Failed to check exercise pass status: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getSubmittedExerciseIdsForMember(courseId: string, groupMemberId: string): Promise<Set<string>> {
try {
const rows = await db
.select({ exerciseId: schema.submission.exerciseId })
.from(schema.submission)
.innerJoin(schema.exercise, eq(schema.submission.exerciseId, schema.exercise.id))
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(
and(
eq(schema.submission.submittedBy, groupMemberId),
or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId))
)
);
return new Set(rows.map((row) => row.exerciseId).filter((id): id is string => !!id));
} catch (error) {
console.error('getSubmittedExerciseIdsForMember error:', error);
throw new Error(`Failed to get submitted exercises: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getCompletedLessonIdsForProfile(courseId: string, profileId: string): Promise<Set<string>> {
try {
const rows = await db
.select({ lessonId: schema.lessonCompletion.lessonId })
.from(schema.lessonCompletion)
.innerJoin(schema.lesson, eq(schema.lessonCompletion.lessonId, schema.lesson.id))
.where(
and(
eq(schema.lesson.courseId, courseId),
eq(schema.lessonCompletion.profileId, profileId),
eq(schema.lessonCompletion.isComplete, true)
)
);
return new Set(rows.map((row) => row.lessonId).filter((id): id is string => !!id));
} catch (error) {
console.error('getCompletedLessonIdsForProfile error:', error);
throw new Error(`Failed to get completed lessons: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getPassedExerciseIdsForMember(
courseId: string,
groupMemberId: string,
policies: ExerciseProgressionPolicy[]
): Promise<Set<string>> {
const passedIds = new Set<string>();
await Promise.all(
policies
.filter((policy) => policy.completionPolicy === 'passed')
.map(async (policy) => {
const threshold = policy.passThreshold ?? 100;
const passed = await isExercisePassedForMember(policy.id, groupMemberId, threshold);
if (passed) passedIds.add(policy.id);
})
);
return passedIds;
}
+1
View File
@@ -1,2 +1,3 @@
export * from './lesson';
export * from './language';
export * from './video-progress';
@@ -0,0 +1,103 @@
import * as schema from '@db/schema';
import type { TLessonVideoProgress, TNewLessonVideoProgress } from '@db/types';
import { and, db, eq } from '@db/drizzle';
export async function getLessonVideoProgress(
lessonId: string,
profileId: string,
assetId: string
): Promise<TLessonVideoProgress | null> {
try {
const [row] = await db
.select()
.from(schema.lessonVideoProgress)
.where(
and(
eq(schema.lessonVideoProgress.lessonId, lessonId),
eq(schema.lessonVideoProgress.profileId, profileId),
eq(schema.lessonVideoProgress.assetId, assetId)
)
)
.limit(1);
return row ?? null;
} catch (error) {
console.error('getLessonVideoProgress error:', error);
throw new Error(
`Failed to get lesson video progress for lesson "${lessonId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getLessonVideoProgressForLesson(
lessonId: string,
profileId: string
): Promise<TLessonVideoProgress[]> {
try {
return db
.select()
.from(schema.lessonVideoProgress)
.where(
and(eq(schema.lessonVideoProgress.lessonId, lessonId), eq(schema.lessonVideoProgress.profileId, profileId))
);
} catch (error) {
console.error('getLessonVideoProgressForLesson error:', error);
throw new Error(
`Failed to get lesson video progress rows for lesson "${lessonId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
function normalizeLessonVideoProgressValues(
data: TNewLessonVideoProgress & { lessonId: string; profileId: string; assetId: string }
): TNewLessonVideoProgress & { lessonId: string; profileId: string; assetId: string } {
return {
...data,
durationSeconds: data.durationSeconds != null ? Math.round(data.durationSeconds) : data.durationSeconds,
watchedSeconds: data.watchedSeconds != null ? Math.round(data.watchedSeconds) : data.watchedSeconds,
furthestSeconds: data.furthestSeconds != null ? Math.floor(data.furthestSeconds) : data.furthestSeconds,
lastPositionSeconds:
data.lastPositionSeconds != null ? Math.floor(data.lastPositionSeconds) : data.lastPositionSeconds
};
}
export async function upsertLessonVideoProgress(
data: TNewLessonVideoProgress & { lessonId: string; profileId: string; assetId: string }
): Promise<TLessonVideoProgress> {
try {
const normalized = normalizeLessonVideoProgressValues(data);
const existing = await getLessonVideoProgress(normalized.lessonId, normalized.profileId, normalized.assetId);
const now = new Date().toISOString();
if (existing) {
const [updated] = await db
.update(schema.lessonVideoProgress)
.set({ ...normalized, updatedAt: now })
.where(eq(schema.lessonVideoProgress.id, existing.id))
.returning();
if (!updated) {
throw new Error('Failed to update lesson video progress');
}
return updated;
}
const [inserted] = await db
.insert(schema.lessonVideoProgress)
.values({ ...normalized, createdAt: now, updatedAt: now })
.returning();
if (!inserted) {
throw new Error('Failed to insert lesson video progress');
}
return inserted;
} catch (error) {
console.error('upsertLessonVideoProgress error:', error);
throw new Error(
`Failed to upsert lesson video progress for lesson "${data.lessonId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
+45 -1
View File
@@ -689,6 +689,8 @@ export const course = pgTable(
allowNewStudent: boolean;
sectionDisplay?: Record<string, boolean>;
isContentGroupingEnabled?: boolean;
/** `free` = all unlocked content is accessible; `sequential` = prior items must be complete. */
progressionMode?: 'free' | 'sequential';
/** Per-course AI tutor override; missing fields inherit from org defaults. */
aiTutor?: {
inheritFromOrg?: boolean;
@@ -726,7 +728,10 @@ export const course = pgTable(
accentColor: string;
subtitle?: string;
descriptionOverride?: string;
signatories: [{ name: string; role: string }, { name: string; role: string }];
signatories: [
{ name: string; role: string; enabled?: boolean; signatureUrl?: string },
{ name: string; role: string; enabled?: boolean; signatureUrl?: string }
];
idFormat?: string;
};
deadline?: string | null;
@@ -975,12 +980,15 @@ export const lesson = pgTable(
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
order: bigint({ mode: 'number' }),
isUnlocked: boolean('is_unlocked').default(false),
completionPolicy: varchar('completion_policy').default('manual').notNull(),
videoWatchThreshold: integer('video_watch_threshold').default(95),
videos: jsonb().default([]).$type<
{
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
link: string;
key?: string;
assetId?: string;
watchEnforced?: boolean;
/** Display name; stored on add/upload. Fallback: derive from key or type. */
fileName?: string;
metadata?: {
@@ -1192,6 +1200,8 @@ export const exercise = pgTable(
dueBy: timestamp('due_by', { mode: 'string' }),
allowMultipleAttempts: boolean('allow_multiple_attempts').default(false).notNull(),
sectionDisplayMode: varchar('section_display_mode').default('one_question'),
completionPolicy: varchar('completion_policy').default('submitted').notNull(),
passThreshold: integer('pass_threshold'),
slug: varchar()
},
(table) => [
@@ -1487,6 +1497,40 @@ export const organizationInviteAudit = pgTable(
]
);
export const lessonVideoProgress = pgTable(
'lesson_video_progress',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
lessonId: uuid('lesson_id').notNull(),
profileId: uuid('profile_id').notNull(),
assetId: text('asset_id').notNull(),
durationSeconds: integer('duration_seconds'),
watchedSeconds: integer('watched_seconds').default(0).notNull(),
furthestSeconds: integer('furthest_seconds').default(0).notNull(),
lastPositionSeconds: integer('last_position_seconds').default(0).notNull(),
isComplete: boolean('is_complete').default(false).notNull(),
completedAt: timestamp('completed_at', { withTimezone: true, mode: 'string' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.lessonId],
foreignColumns: [lesson.id],
name: 'lesson_video_progress_lesson_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.profileId],
foreignColumns: [profile.id],
name: 'lesson_video_progress_profile_id_fkey'
}),
unique('lesson_video_progress_lesson_profile_asset_unique').on(table.lessonId, table.profileId, table.assetId)
]
);
export const lessonCompletion = pgTable(
'lesson_completion',
{
+3
View File
@@ -112,6 +112,9 @@ export type TNewOrganizationInviteAudit = typeof schema.organizationInviteAudit.
export type TLessonCompletion = typeof schema.lessonCompletion.$inferSelect;
export type TNewLessonCompletion = typeof schema.lessonCompletion.$inferInsert;
export type TLessonVideoProgress = typeof schema.lessonVideoProgress.$inferSelect;
export type TNewLessonVideoProgress = typeof schema.lessonVideoProgress.$inferInsert;
export type TCommunityAnswer = typeof schema.communityAnswer.$inferSelect;
export type TNewCommunityAnswer = typeof schema.communityAnswer.$inferInsert;