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'); }