From 27229cf8fe3d2321094d37d3bad41bfb5591adfa Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Thu, 16 Jul 2026 22:10:13 +0100 Subject: [PATCH] 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 * 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 --- apps/api/src/routes/course/exercise.ts | 2 +- apps/api/src/services/course/completion.ts | 8 +- .../api/src/services/submission/submission.ts | 27 ++-- .../unlocked-certificate.svelte | 4 +- .../components/exercise/view-mode.svelte | 6 +- .../lesson/content-navigation-actions.svelte | 5 +- .../lesson/video/lesson-video-player.svelte | 6 +- package.json | 3 +- .../core/src/services/course/progression.ts | 39 ++---- packages/db/src/queries/course/content.ts | 34 +---- packages/db/src/queries/course/course.ts | 16 +-- packages/db/src/queries/course/progression.ts | 127 ++++++++++-------- .../organizationmember-email-notifications.ts | 26 ++-- 13 files changed, 126 insertions(+), 177 deletions(-) diff --git a/apps/api/src/routes/course/exercise.ts b/apps/api/src/routes/course/exercise.ts index 11dd10a9..bb93f7fc 100644 --- a/apps/api/src/routes/course/exercise.ts +++ b/apps/api/src/routes/course/exercise.ts @@ -16,7 +16,6 @@ import { createExercise, createExerciseFromTemplate, deleteExerciseForCourseService, - deleteExerciseService, getExercise, listExercises, updateExerciseService @@ -83,6 +82,7 @@ export const exerciseRouter = new Hono() const user = c.get('user')!; const data = await listExerciseSubmissionsOverview(courseId, exerciseId, user.id); + return c.json({ success: true, data }, 200); } catch (error) { return handleError(c, error, 'Failed to fetch exercise submissions overview'); diff --git a/apps/api/src/services/course/completion.ts b/apps/api/src/services/course/completion.ts index 3c38d5cf..05a8f398 100644 --- a/apps/api/src/services/course/completion.ts +++ b/apps/api/src/services/course/completion.ts @@ -17,7 +17,6 @@ import { } from '@cio/db/queries/course/course'; import { claimMemberCertificateEarned } from '@cio/db/queries/course/people'; import { getActiveOrganizationPlan } from '@cio/db/queries/organization'; -import { ROLE } from '@cio/utils/constants'; import { PLAN } from '@cio/utils/plans'; import { env } from '@cio/core/config/env'; import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics'; @@ -193,12 +192,7 @@ async function buildCertificationEvaluation( blockers.push(...finalEval.blockers); const eligibleForCertificate = - progress.roleId === ROLE.STUDENT && - !!progress.groupMemberId && - meetsThreshold && - hasContent && - withinDeadline && - finalEval.meetsFinalExerciseRule; + !!progress.groupMemberId && meetsThreshold && hasContent && withinDeadline && finalEval.meetsFinalExerciseRule; return { progressPercent, diff --git a/apps/api/src/services/submission/submission.ts b/apps/api/src/services/submission/submission.ts index bbf164eb..0f4cc76b 100644 --- a/apps/api/src/services/submission/submission.ts +++ b/apps/api/src/services/submission/submission.ts @@ -45,7 +45,7 @@ import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from '@cio/core/utils/s3'; import { syncComplianceProgressFromSubmission } from '@api/services/course/compliance'; import { evaluateCourseCertification } from '@api/services/course/completion'; -import { isExercisePassedForMember } from '@cio/db/queries/course/progression'; +import { isExerciseCompletedForMember } from '@cio/db/queries/course/progression'; type SubmissionGradingState = 'queued' | 'processing' | 'awaiting_manual' | 'completed' | 'failed'; type SubmissionOverallStatus = 'auto_graded' | 'manual_required' | 'hybrid'; @@ -55,19 +55,11 @@ async function triggerCertificationIfExerciseComplete( exerciseId: string, groupMemberId: string ): Promise { - const exercise = await getExerciseById(exerciseId); - if (!exercise) return; - const profile = await getProfileByGroupMemberId(groupMemberId); if (!profile?.id) return; - const policy = exercise.completionPolicy ?? 'submitted'; - - if (policy === 'passed') { - const threshold = exercise.passThreshold ?? 100; - const passed = await isExercisePassedForMember(exerciseId, groupMemberId, threshold); - if (!passed) return; - } + const isComplete = await isExerciseCompletedForMember(exerciseId, groupMemberId); + if (!isComplete) return; void evaluateCourseCertification(courseId, profile.id).catch((certError) => { console.error('Failed to evaluate course certification after exercise submission:', certError); @@ -361,14 +353,15 @@ export async function listExerciseSubmissionsOverview( isCourseTeamMemberOrOrgAdmin(courseId, profileId) ]); - if (isInstructor) { - const allSubmissions = await listSubmissionsByExercise(courseId, exerciseId); - return { mySubmission: [], allSubmissions }; - } - const mySubmission = groupMemberId ? await listSubmissionsByExercise(courseId, exerciseId, groupMemberId) : []; - return { mySubmission, allSubmissions: [] }; + if (!isInstructor) { + return { mySubmission, allSubmissions: [] }; + } + + const allSubmissions = await listSubmissionsByExercise(courseId, exerciseId); + + return { mySubmission, allSubmissions }; } catch (error) { throw new AppError( error instanceof Error ? error.message : 'Failed to list exercise submissions overview', diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/student-certificate/unlocked-certificate.svelte b/apps/dashboard/src/lib/features/course/components/ceritficate/student-certificate/unlocked-certificate.svelte index 7ce4c7d8..1772f954 100644 --- a/apps/dashboard/src/lib/features/course/components/ceritficate/student-certificate/unlocked-certificate.svelte +++ b/apps/dashboard/src/lib/features/course/components/ceritficate/student-certificate/unlocked-certificate.svelte @@ -12,7 +12,7 @@ import { classroomio } from '$lib/utils/services/api'; import type { CertificationEvaluationData } from '$features/course/utils/types'; import { normalizeCertificateIssuedAt, formatBlockerMessage } from '$features/course/utils/certificate-utils'; - import { openCourseCompletionModal } from '$features/course/store/course-completion-modal'; + import { updateCourseCompletionModal } from '$features/course/store/course-completion-modal'; let isLoading = $state(false); let isPngLoading = $state(false); @@ -94,7 +94,7 @@ isCourseComplete = hasEarned || !!res.data.eligibleForCertificate; if (res.data.isNewCompletion && courseId) { - openCourseCompletionModal(courseId); + updateCourseCompletionModal(courseId, 'eligible', res.data); } } isLoading = false; diff --git a/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte b/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte index 23e032af..3816c9d7 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte @@ -39,7 +39,6 @@ import { toggleConfetti } from '$features/ui/confetti/store'; import { ContentType } from '@cio/utils/constants/content'; import { getOrderedNavigableContent } from '$features/course/utils/content'; - import { isOrgStudent } from '$lib/utils/store/app'; import { openCourseCompletionModal, updateCourseCompletionModal, @@ -181,10 +180,7 @@ const allContentItems = getOrderedNavigableContent(courseApi.course); const allComplete = - shouldMarkComplete && - $isOrgStudent && - allContentItems.length > 0 && - allContentItems.every((item) => item.isComplete); + shouldMarkComplete && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete); if (allComplete && courseApi.course?.id) { const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined; diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte b/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte index 9ec965d8..5806c72e 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte @@ -166,10 +166,7 @@ updateCourseContentCompletion(currentLessonId, isComplete); const allComplete = - get(isCourseLearnerView) && - isComplete && - navigableContentItems.length > 0 && - navigableContentItems.every((item) => item.isComplete); + isComplete && navigableContentItems.length > 0 && navigableContentItems.every((item) => item.isComplete); if (allComplete) { const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined; diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-player.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-player.svelte index 81a44f50..430a6ab7 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-player.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-player.svelte @@ -12,7 +12,7 @@ import { lessonApi } from '$features/course/api'; import { snackbar } from '$features/ui/snackbar/store'; import type { AssetTranscriptPayload } from '$features/media/utils/types'; - import { isOrgStudent } from '$lib/utils/store/app'; + import { isCourseLearnerView } from '$lib/utils/store/app'; import { resolveWatchEnforcedAssetIds, type LessonVideo } from './video-card-utils'; import { lessonVideoBus } from './lesson-video-bus.svelte'; import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition'; @@ -345,7 +345,7 @@ const seekPolicy = $derived.by(() => { const lesson = lessonApi.lesson; - if (!$isOrgStudent || !lesson || lesson.completionPolicy !== 'video_watch' || !isWatchEnforcedForVideo) { + if (!$isCourseLearnerView || !lesson || lesson.completionPolicy !== 'video_watch' || !isWatchEnforcedForVideo) { return undefined; } @@ -373,7 +373,7 @@ }); async function restoreWatchProgress(): Promise { - if (!$isOrgStudent || !uploadAssetId) return; + if (!$isCourseLearnerView || !uploadAssetId) return; const cachedAsset = assetWatchProgress; if (cachedAsset?.lastPositionSeconds && cachedAsset.lastPositionSeconds > 0) { diff --git a/package.json b/package.json index 131e98fb..a2ee70f9 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "db:studio": "pnpm --filter @cio/db db:studio", "db:backfill-profile-email-verification": "pnpm --filter @cio/db db:backfill-profile-email-verification", "dashboard:dev": "pnpm concurrently -n dashboard,ui -c blue,yellow \"pnpm --filter=@cio/dashboard run dev\" \"pnpm --filter=@cio/ui run dev\"", - "api:dev": "pnpm turbo run build --filter=@cio/api^... --filter=@cio/jobs-worker^... && pnpm concurrently -n api,jobs -c blue,yellow \"pnpm --filter=@cio/api run dev\" \"pnpm --filter=@cio/jobs-worker run dev\"", + "api:dev:full": "pnpm turbo run build --filter=@cio/api^... --filter=@cio/jobs-worker^... && pnpm concurrently -n api,jobs -c blue,yellow \"pnpm --filter=@cio/api run dev\" \"pnpm --filter=@cio/jobs-worker run dev\"", + "api:dev": "pnpm concurrently -n api,jobs -c blue,yellow \"pnpm --filter=@cio/api run dev\" \"pnpm --filter=@cio/jobs-worker run dev\"", "jobs:dev": "pnpm --filter=@cio/jobs-worker run dev", "docs:dev": "pnpm --filter @cio/docs dev", "docs:build": "pnpm --filter @cio/docs build", diff --git a/packages/core/src/services/course/progression.ts b/packages/core/src/services/course/progression.ts index 8bd5d4c7..c1000061 100644 --- a/packages/core/src/services/course/progression.ts +++ b/packages/core/src/services/course/progression.ts @@ -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; - submittedExerciseIds: Set; - passedExerciseIds: Set; + completedExerciseIds: Set; }): 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; - exercisePolicyById: Map; progressionMode: 'free' | 'sequential'; completedLessonIds: Set; - submittedExerciseIds: Set; - passedExerciseIds: Set; + completedExerciseIds: Set; }): Map { const accessById = new Map(); 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()) ]); - const passedExerciseIds = groupMemberId - ? await getPassedExerciseIdsForMember(params.courseId, groupMemberId, exercisePolicies) - : new Set(); - 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); diff --git a/packages/db/src/queries/course/content.ts b/packages/db/src/queries/course/content.ts index bb5e95d5..5fe56416 100644 --- a/packages/db/src/queries/course/content.ts +++ b/packages/db/src/queries/course/content.ts @@ -2,6 +2,7 @@ import { sql } from 'drizzle-orm'; import { ContentType } from '@cio/utils/constants'; import { db, sql as drizzleSql } from '@db/drizzle'; +import { isExerciseCompletedSql } from './progression'; export type CourseContentItemRow = { id: string; @@ -40,38 +41,7 @@ export async function getCourseContentItems(courseId: string, profileId?: string )` : drizzleSql`false`; - const exerciseCompletionSql = profileId - ? drizzleSql`COALESCE( - 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`; + const exerciseCompletionSql = profileId ? isExerciseCompletedSql('exercise', { profileId }) : drizzleSql`false`; const result = await db.execute(drizzleSql` SELECT diff --git a/packages/db/src/queries/course/course.ts b/packages/db/src/queries/course/course.ts index 3631a7ec..9e970310 100644 --- a/packages/db/src/queries/course/course.ts +++ b/packages/db/src/queries/course/course.ts @@ -16,6 +16,7 @@ import { and, count, desc, eq, gt, ilike, inArray, isNotNull, isNull, or, sql } import { ROLE } from '@cio/utils/constants'; import { db, type DbOrTxClient } from '@db/drizzle'; import { getCourseContentItems, type CourseContentItemRow } from './content'; +import { isExerciseCompletedSql } from './progression'; import { getUpcomingSessionsForCourseIds, type CourseUpcomingSession } from './session'; /** @@ -624,15 +625,14 @@ export async function getCourseProgress( db .select({ - exercisesCompleted: sql`count(distinct ${schema.exercise.id})::int` + exercisesCompleted: sql`count(*)::int` }) - .from(schema.submission) - .innerJoin(schema.exercise, eq(schema.submission.exerciseId, schema.exercise.id)) + .from(schema.exercise) .leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id)) .where( and( or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)), - eq(schema.submission.submittedBy, groupMemberId) + isExerciseCompletedSql('exercise', { groupMemberId }) ) ) ]); @@ -997,14 +997,12 @@ export const getEnrolledCourses = async ({ WHERE ${or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id))} )`.as('exercise_count'), exercisesCompleted: sql`( - SELECT COUNT(DISTINCT s.exercise_id)::bigint - FROM ${schema.submission} as s - JOIN ${schema.exercise} as ex ON ex.id = s.exercise_id + SELECT COUNT(*)::bigint + FROM ${schema.exercise} as ex LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id - JOIN ${schema.groupmember} as gm ON gm.id = s.submitted_by WHERE ${and( or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id)), - sql`gm.profile_id = ${sql.raw(`'${profileId}'::uuid`)}` + isExerciseCompletedSql('ex', { profileId }) )} )`.as('exercises_completed'), certificateEarnedAt: schema.groupmember.certificateEarnedAt, diff --git a/packages/db/src/queries/course/progression.ts b/packages/db/src/queries/course/progression.ts index 6322037e..1ae14e89 100644 --- a/packages/db/src/queries/course/progression.ts +++ b/packages/db/src/queries/course/progression.ts @@ -1,9 +1,59 @@ import * as schema from '@db/schema'; -import { and, eq, or } from 'drizzle-orm'; +import { and, eq, or, sql, type SQL } from 'drizzle-orm'; import { db } from '@db/drizzle'; -import { getExerciseTitleAndMaxPoints, getStudentSubmissionsForExercise } from './certification-exercise'; + +export type ExerciseCompletionMember = { profileId: string } | { groupMemberId: string }; + +/** Allowed aliases for the exercise table in queries embedding the completion predicate. */ +export type ExerciseAlias = 'exercise' | 'ex'; + +/** + * Single source of truth for whether an exercise counts as completed for a student. + * + * Honors the exercise's completion policy: + * - 'submitted' (default): any submission counts + * - 'passed': a graded submission must score >= pass_threshold (default 100) + * + * Returns a boolean SQL predicate to embed in a query where the exercise table + * is in scope under `exerciseAlias`. Every progress/completion computation + * (course progress, enrolled courses, content items, progression locks) must + * go through this predicate so completion reads the same everywhere. + */ +export function isExerciseCompletedSql(exerciseAlias: ExerciseAlias, member: ExerciseCompletionMember): SQL { + const exercise = sql.raw(exerciseAlias); + const memberFilter = + 'groupMemberId' in member + ? sql`cs.submitted_by = ${member.groupMemberId}` + : sql`EXISTS ( + SELECT 1 + FROM ${schema.groupmember} AS cgm + WHERE cgm.id = cs.submitted_by AND cgm.profile_id = ${member.profileId} + )`; + + return sql`EXISTS ( + SELECT 1 + FROM ${schema.submission} AS cs + WHERE cs.exercise_id = ${exercise}.id + AND ${memberFilter} + AND ( + ${exercise}.completion_policy != 'passed' + OR ( + cs.grading_state = 'completed' + AND ( + SELECT CASE + WHEN COALESCE(SUM(question.points), 0) <= 0 THEN false + ELSE ROUND((cs.total::numeric / SUM(question.points)::numeric) * 100) + >= COALESCE(${exercise}.pass_threshold, 100) + END + FROM ${schema.question} + WHERE question.exercise_id = ${exercise}.id + ) + ) + ) + )`; +} export type LessonProgressionPolicy = { id: string; @@ -58,59 +108,40 @@ export async function getExerciseProgressionPolicies(courseId: string): Promise< } } -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 { +export async function isExerciseCompletedForMember(exerciseId: string, groupMemberId: string): Promise { try { - const { maxPoints } = await getExerciseTitleAndMaxPoints(exerciseId); - if (maxPoints <= 0) return false; + const [row] = await db + .select({ id: schema.exercise.id }) + .from(schema.exercise) + .where(and(eq(schema.exercise.id, exerciseId), isExerciseCompletedSql('exercise', { groupMemberId }))) + .limit(1); - 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; + return !!row; } catch (error) { - console.error('isExercisePassedForMember error:', error); + console.error('isExerciseCompletedForMember error:', error); throw new Error( - `Failed to check exercise pass status: ${error instanceof Error ? error.message : 'Unknown error'}` + `Failed to check exercise completion status: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } -export async function getSubmittedExerciseIdsForMember(courseId: string, groupMemberId: string): Promise> { +export async function getCompletedExerciseIdsForMember(courseId: string, groupMemberId: string): Promise> { try { const rows = await db - .select({ exerciseId: schema.submission.exerciseId }) - .from(schema.submission) - .innerJoin(schema.exercise, eq(schema.submission.exerciseId, schema.exercise.id)) + .select({ id: schema.exercise.id }) + .from(schema.exercise) .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)) + or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)), + isExerciseCompletedSql('exercise', { groupMemberId }) ) ); - return new Set(rows.map((row) => row.exerciseId).filter((id): id is string => !!id)); + return new Set(rows.map((row) => row.id)); } catch (error) { - console.error('getSubmittedExerciseIdsForMember error:', error); - throw new Error(`Failed to get submitted exercises: ${error instanceof Error ? error.message : 'Unknown error'}`); + console.error('getCompletedExerciseIdsForMember error:', error); + throw new Error(`Failed to get completed exercises: ${error instanceof Error ? error.message : 'Unknown error'}`); } } @@ -134,23 +165,3 @@ export async function getCompletedLessonIdsForProfile(courseId: string, profileI 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> { - const passedIds = new Set(); - - 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; -} diff --git a/packages/db/src/queries/notifications/organizationmember-email-notifications.ts b/packages/db/src/queries/notifications/organizationmember-email-notifications.ts index 98bcbf98..4d4c3e5f 100644 --- a/packages/db/src/queries/notifications/organizationmember-email-notifications.ts +++ b/packages/db/src/queries/notifications/organizationmember-email-notifications.ts @@ -32,6 +32,18 @@ function rowFromRecord( }; } +async function getEmailNotificationsByMemberId( + organizationMemberId: number +): Promise { + const [record] = await db + .select() + .from(schema.organizationmemberEmailNotifications) + .where(eq(schema.organizationmemberEmailNotifications.organizationMemberId, organizationMemberId)) + .limit(1); + + return rowFromRecord(record); +} + export async function getOrganizationMemberEmailNotifications( profileId: string, organizationId: string @@ -43,13 +55,7 @@ export async function getOrganizationMemberEmailNotifications( return null; } - const [record] = await db - .select() - .from(schema.organizationmemberEmailNotifications) - .where(eq(schema.organizationmemberEmailNotifications.organizationMemberId, organizationMemberId)) - .limit(1); - - return rowFromRecord(record); + return getEmailNotificationsByMemberId(organizationMemberId); } catch (error) { console.error('getOrganizationMemberEmailNotifications error:', error); throw new Error('Failed to get organization member email notification preferences'); @@ -83,7 +89,7 @@ export async function upsertOrganizationMemberEmailNotifications( throw new Error('Organization member not found'); } - const existing = await getOrganizationMemberEmailNotifications(profileId, organizationId); + const existing = await getEmailNotificationsByMemberId(organizationMemberId); const merged = personalSettingsToMemberEmailNotificationRow({ ...(existing ?? DEFAULT_MEMBER_EMAIL_NOTIFICATION_ROW), ...preferences @@ -114,6 +120,10 @@ export async function upsertOrganizationMemberEmailNotifications( return row; } catch (error) { + if (error instanceof Error && error.message === 'Organization member not found') { + throw error; + } + console.error('upsertOrganizationMemberEmailNotifications error:', error); throw new Error('Failed to update organization member email notification preferences'); }