fix: canonical exercise completion, teacher course completion & upsert member fixes (#767)

* fix(db): preserve not-found error and dedupe member lookup in upsert (#763)

Re-throw Organization member not found so the service layer can return
404. Fetch existing notification prefs by resolved member id instead of
calling getOrganizationMemberEmailNotifications, which repeated the lookup.

Co-authored-by: Rotimi Best <[email protected]>

* fix(course): single source of truth for exercise completion; let teachers complete courses

Exercise completion was computed four different ways (course progress,
enrolled courses, content items, progression locks), and two of them
counted any submission as complete, ignoring the exercise's 'passed'
completion policy and pass threshold. My Learning and certificate
eligibility could therefore report 100% while the course itself showed
an unpassed exercise.

- Add isExerciseCompletedSql, a policy-aware SQL predicate in the db
  query layer, and route every completion computation through it
  (getCourseProgress, getEnrolledCourses, getCourseContentItems,
  getCompletedExerciseIdsForMember, isExerciseCompletedForMember).
- Remove the per-exercise N+1 pass checks in progression and the
  duplicated policy interpretation in the submission service.
- Fix infinite "Checking eligibility" modal on the certificates page:
  open the completion modal in its final state instead of 'checking'
  with no follow-up.
- Align teacher experience with students: certificate eligibility no
  longer requires the student role, completion modal triggers for any
  enrolled member, teachers can view/download their earned certificate,
  and video-watch progress tracks in the student experience regardless
  of org role.

* chore: run api:dev without prebuild; keep full build as api:dev:full

* fix(dashboard): gate video watch tracking on learner view

* feat: pr reviews

---------

Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-16 22:10:13 +01:00
committed by GitHub
co-authored by Rotimi Best
parent ca3fdd5b6a
commit 27229cf8fe
13 changed files with 126 additions and 177 deletions
@@ -3,11 +3,10 @@ import { ROLE } from '@cio/utils/constants';
import { AppError, ErrorCodes } from '@cio/utils/errors';
import type { CourseContentItemRow } from '@cio/db/queries/course/content';
import {
getCompletedExerciseIdsForMember,
getCompletedLessonIdsForProfile,
getExerciseProgressionPolicies,
getLessonProgressionPolicies,
getPassedExerciseIdsForMember,
getSubmittedExerciseIdsForMember,
type ExerciseProgressionPolicy,
type LessonProgressionPolicy
} from '@cio/db/queries/course/progression';
@@ -66,25 +65,17 @@ function exerciseBlocksProgression(): boolean {
function isItemProgressComplete(params: {
item: CourseContentItem;
lessonPolicy?: LessonProgressionPolicy;
exercisePolicy?: ExerciseProgressionPolicy;
completedLessonIds: Set<string>;
submittedExerciseIds: Set<string>;
passedExerciseIds: Set<string>;
completedExerciseIds: Set<string>;
}): boolean {
const { item, lessonPolicy, exercisePolicy, completedLessonIds, submittedExerciseIds, passedExerciseIds } = params;
const { item, completedLessonIds, completedExerciseIds } = 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 completedExerciseIds.has(item.id) || Boolean(item.isComplete);
}
return true;
@@ -93,18 +84,15 @@ function isItemProgressComplete(params: {
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>;
completedExerciseIds: 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 ?? true);
const progressionLocked = params.progressionMode === 'sequential' && !priorBlockingComplete;
@@ -123,11 +111,8 @@ function annotateNavigableAccess(params: {
const complete = isItemProgressComplete({
item,
lessonPolicy,
exercisePolicy,
completedLessonIds: params.completedLessonIds,
submittedExerciseIds: params.submittedExerciseIds,
passedExerciseIds: params.passedExerciseIds
completedExerciseIds: params.completedExerciseIds
});
const blocks =
@@ -212,30 +197,24 @@ export async function annotateCourseContentWithProgression(params: {
const progressionMode = params.progressionMode ?? 'free';
const groupMemberId = await getGroupMemberIdByCourseAndProfile(params.courseId, params.profileId);
const [lessonPolicies, exercisePolicies, completedLessonIds, submittedExerciseIds] = await Promise.all([
const [lessonPolicies, exercisePolicies, completedLessonIds, completedExerciseIds] = await Promise.all([
getLessonProgressionPolicies(params.courseId),
getExerciseProgressionPolicies(params.courseId),
getCompletedLessonIdsForProfile(params.courseId, params.profileId),
groupMemberId
? getSubmittedExerciseIdsForMember(params.courseId, groupMemberId)
? getCompletedExerciseIdsForMember(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
completedExerciseIds
});
return applyAccessToContent(content, accessById, lessonPolicyById, exercisePolicyById);