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