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:
co-authored by
Rotimi Best
parent
ca3fdd5b6a
commit
27229cf8fe
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user