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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>
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
+1 -1
View File
@@ -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');
+1 -7
View File
@@ -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,
+10 -17
View File
@@ -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<void> {
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',
@@ -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;
@@ -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;
@@ -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;
@@ -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<void> {
if (!$isOrgStudent || !uploadAssetId) return;
if (!$isCourseLearnerView || !uploadAssetId) return;
const cachedAsset = assetWatchProgress;
if (cachedAsset?.lastPositionSeconds && cachedAsset.lastPositionSeconds > 0) {
+2 -1
View File
@@ -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",
@@ -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);
+2 -32
View File
@@ -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
+7 -9
View File
@@ -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<number>`count(distinct ${schema.exercise.id})::int`
exercisesCompleted: sql<number>`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<number>`(
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,
+69 -58
View File
@@ -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<boolean> {
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<boolean>`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<boolean> {
export async function isExerciseCompletedForMember(exerciseId: string, groupMemberId: string): Promise<boolean> {
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<Set<string>> {
export async function getCompletedExerciseIdsForMember(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))
.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<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;
}
@@ -32,6 +32,18 @@ function rowFromRecord(
};
}
async function getEmailNotificationsByMemberId(
organizationMemberId: number
): Promise<MemberEmailNotificationRow | null> {
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');
}