diff --git a/apps/api/src/services/course/completion.ts b/apps/api/src/services/course/completion.ts index 9907a3a8..3c38d5cf 100644 --- a/apps/api/src/services/course/completion.ts +++ b/apps/api/src/services/course/completion.ts @@ -15,7 +15,11 @@ import { getCourseProgress as getCourseProgressQuery, type TCourseCertificationRow } 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'; export type CertificationBlocker = { @@ -230,38 +234,58 @@ export async function evaluateCourseCertification( let isNewCompletion = false; if (evaluation.eligibleForCertificate && !certificateEarnedAt && progress.groupMemberId) { - const earnedAt = new Date().toISOString(); - certificateEarnedAt = earnedAt; - isNewCompletion = true; + const certificatesEnabled = await orgHasCertificatesEnabled(courseRow.orgId); - scheduleCertificationCompletionWork({ - courseId, - profileId, - groupMemberId: progress.groupMemberId, - courseRow, - earnedAt - }); + if (certificatesEnabled) { + const earnedAt = new Date().toISOString(); + const didClaimCertificate = await claimMemberCertificateEarned(progress.groupMemberId, earnedAt); + certificateEarnedAt = earnedAt; - trackServerEvent({ - eventType: SERVER_EVENTS.COURSE_COMPLETED, - userId: profileId, - courseId, - props: { path: 'standard', earnedAt } - }); + if (didClaimCertificate) { + isNewCompletion = true; - if (courseRow.certificate?.isDownloadable) { - trackServerEvent({ - eventType: SERVER_EVENTS.CERTIFICATE_ISSUED, - userId: profileId, - courseId, - props: { path: 'standard', earnedAt } - }); + scheduleCertificationCompletionWork({ + courseId, + profileId, + groupMemberId: progress.groupMemberId, + courseRow, + earnedAt + }); + + trackServerEvent({ + eventType: SERVER_EVENTS.COURSE_COMPLETED, + userId: profileId, + courseId, + props: { path: 'standard', earnedAt } + }); + + if (courseRow.certificate?.isDownloadable) { + trackServerEvent({ + eventType: SERVER_EVENTS.CERTIFICATE_ISSUED, + userId: profileId, + courseId, + props: { path: 'standard', earnedAt } + }); + } + } } } return { ...evaluation, certificateEarnedAt, isNewCompletion }; } +/** + * Free-plan orgs do not get certificates: no earned record, no completion email. + * Self-hosted deployments always have certificates enabled. + */ +async function orgHasCertificatesEnabled(orgId: string): Promise { + if (env.PUBLIC_IS_SELFHOSTED === 'true') return true; + + const activePlan = await getActiveOrganizationPlan(orgId); + + return Boolean(activePlan && activePlan.planName !== PLAN.BASIC); +} + /** * Server-side guard for PDF certificate download (must match dashboard eligibility). */ diff --git a/apps/api/src/services/exercise/utils.ts b/apps/api/src/services/exercise/utils.ts index 44f295ff..5b39dddd 100644 --- a/apps/api/src/services/exercise/utils.ts +++ b/apps/api/src/services/exercise/utils.ts @@ -12,11 +12,19 @@ import { updateQuestions } from '@cio/db/queries/exercise'; +import { + QUESTION_TYPE_IDS, + QUESTION_TYPE_KEY, + resolveTrueFalseCorrectValue, + syncTrueFalseOptions, + type TrueFalseAnswerOption +} from '@cio/question-types'; import type { DbOrTxClient } from '@cio/db/drizzle'; import type { TExerciseUpdate } from '@cio/utils/validation/exercise'; type CurrentQuestion = TNewQuestion & { options?: TNewOption[] }; type CurrentOption = TNewOption; +type ExerciseUpdateQuestion = NonNullable[number]; type ExerciseAnswer = Pick< TQuestionAnswer, @@ -68,6 +76,26 @@ function areSettingsEqual(left: unknown, right: unknown): boolean { return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized); } +const TRUE_FALSE_QUESTION_TYPE_ID = QUESTION_TYPE_IDS[QUESTION_TYPE_KEY.TRUE_FALSE]; + +function normalizeTrueFalseQuestion( + question: ExerciseUpdateQuestion, + currentQuestion?: CurrentQuestion +): ExerciseUpdateQuestion { + const questionTypeId = question.questionTypeId ?? currentQuestion?.questionTypeId; + if (questionTypeId !== TRUE_FALSE_QUESTION_TYPE_ID) return question; + + const settings = normalizeSettings(question.settings ?? currentQuestion?.settings); + const options = question.options ?? currentQuestion?.options ?? []; + const correctValue = resolveTrueFalseCorrectValue(settings, options as TrueFalseAnswerOption[]); + + return { + ...question, + settings: { ...settings, correctValue }, + options: question.options ? syncTrueFalseOptions(question.options, correctValue) : question.options + }; +} + /** * Extracts exercise-level fields from the update payload */ @@ -81,6 +109,8 @@ export function buildExerciseUpdateFields(data: TExerciseUpdate): Partial }> = []; - for (const q of questions) { + for (const rawQuestion of questions) { + const q = normalizeTrueFalseQuestion(rawQuestion); + if (q.deletedAt && q.id) { deletedIds.push(q.id); } else if (q.id) { @@ -230,13 +262,16 @@ export function computeExerciseDiff( incomingQuestions: NonNullable ) { const questionMap = new Map(currentQuestions.map((q) => [q.id, q])); + const normalizedIncomingQuestions = incomingQuestions.map((incoming) => + normalizeTrueFalseQuestion(incoming, incoming.id ? questionMap.get(incoming.id) : undefined) + ); const deletedQuestionIds: number[] = []; const questionUpdates: Array<{ id: number; data: Partial }> = []; const newQuestions: NonNullable = []; // Process incoming questions - for (const incoming of incomingQuestions) { + for (const incoming of normalizedIncomingQuestions) { if (incoming.deletedAt && incoming.id) { deletedQuestionIds.push(incoming.id); } else if (incoming.id) { @@ -260,7 +295,7 @@ export function computeExerciseDiff( const optionUpdates: Array<{ id: number; data: Partial }> = []; const optionCreates: TNewOption[] = []; - for (const incoming of incomingQuestions) { + for (const incoming of normalizedIncomingQuestions) { if (!incoming.id || incoming.deletedAt) continue; const current = questionMap.get(incoming.id); @@ -336,8 +371,9 @@ export async function createNewQuestionsWithOptions( newQuestions: NonNullable, txClient: DbOrTxClient ): Promise { + const normalizedQuestions = newQuestions.map((question) => normalizeTrueFalseQuestion(question)); const createdQuestions = await createQuestions( - newQuestions.map((q) => ({ + normalizedQuestions.map((q) => ({ exerciseId, title: q.question, questionTypeId: q.questionTypeId || 1, @@ -351,7 +387,7 @@ export async function createNewQuestionsWithOptions( const newOptions: TNewOption[] = []; createdQuestions.forEach((newQuestion, index) => { - const questionData = newQuestions[index]; + const questionData = normalizedQuestions[index]; if (newQuestion && questionData?.options && questionData.options.length > 0) { // Only include non-deleted options for new questions for (const opt of questionData.options) { diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/course-completion-modal.svelte b/apps/dashboard/src/lib/features/course/components/ceritficate/course-completion-modal.svelte index 71e86948..1488e839 100644 --- a/apps/dashboard/src/lib/features/course/components/ceritficate/course-completion-modal.svelte +++ b/apps/dashboard/src/lib/features/course/components/ceritficate/course-completion-modal.svelte @@ -11,6 +11,7 @@ import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal'; import { getContentRoute } from '$features/course/utils/content'; import { formatBlockerMessage } from '$features/course/utils/certificate-utils'; + import { isFreePlan } from '$lib/utils/store/org'; import { t } from '$lib/utils/functions/translations'; let open = $state(false); @@ -84,11 +85,15 @@ {:else if activeStep === 'eligible'} {$t('course.completion.modal.title')} - {$t('course.completion.modal.subtitle')} + + {$isFreePlan ? $t('course.completion.modal.subtitle_no_certificate') : $t('course.completion.modal.subtitle')} + - + {#if !$isFreePlan} + + {/if} {:else if activeStep === 'not-eligible'} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte b/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte index 9abec214..b53d8453 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte @@ -1,6 +1,7 @@ {#if isLoading} -{:else if $submissions?.length} +{:else if submissionGroups?.length}
- {#each $submissions as student, i (student.id)} + {#each submissionGroups as studentGroup, i (studentGroup.studentKey)} {/each}
-

- {$submissions[studentSelected].groupmember?.profile.fullname ?? ''}'s {$t( - 'course.navItem.lessons.exercises.all_exercises.analytics.individual.answers' - )} -

- {#if $submissions[studentSelected]} + {@const selectedGroup = getSelectedGroup()} + {#if selectedGroup} + {@const selectedAttemptIndex = getSelectedAttemptIndex(selectedGroup)} + {@const selectedAttempt = selectedGroup.attempts[selectedAttemptIndex]} + {@const selectedSubmission = selectedAttempt.submission} +
+
+

+ {$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.answers_for', { + name: selectedGroup.studentName + })} +

+

+ {$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.attempts_count', { + count: selectedGroup.attempts.length + })} +

+
+ +
+ selectAttempt(selectedGroup, value)} + > + + {$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.attempt_label', { + number: selectedAttempt.attemptNumber + })} + - + {getStatusLabel(selectedSubmission)} + + + {#each selectedGroup.attempts as attempt, attemptIndex (attempt.submission.id)} + {@const attemptScore = getTotalScore(attempt.submission)} + + {$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.attempt_label', { + number: attempt.attemptNumber + })} + - + {formatSubmittedAt(attempt.submission)} + - + {getStatusLabel(attempt.submission)} + - + {attemptScore}/{getTotalPossiblePoints()} + - + {getAttemptPassLabel(attempt.submission)} + + {/each} + + +
+
+ +
+ {getStatusLabel(selectedSubmission)} + + {getTotalScore(selectedSubmission)}/{getTotalPossiblePoints()} + {$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')} + + + {getAttemptPassLabel(selectedSubmission)} + +
+ {#if hasSectionGroups}
{#each activeSections as section, sectionIndex (section.id)} @@ -124,7 +260,7 @@ contract={{ mode: 'review', question: toExerciseQuestionModel(q), - answer: getStudentAnswerForQuestion($submissions[studentSelected], q), + answer: getStudentAnswerForQuestion(selectedSubmission, q), labels: questionLabels, disabled: true }} @@ -135,7 +271,7 @@ {/each}

- {getSectionScore($submissions[studentSelected], sectionQuestions)}/{getSectionMaxPoints(sectionQuestions)} + {getSectionScore(selectedSubmission, sectionQuestions)}/{getSectionMaxPoints(sectionQuestions)} {$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}

@@ -148,7 +284,7 @@ contract={{ mode: 'review', question: toExerciseQuestionModel(q), - answer: getStudentAnswerForQuestion($submissions[studentSelected], q), + answer: getStudentAnswerForQuestion(selectedSubmission, q), labels: questionLabels, disabled: true }} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/submissions/submissions.svelte b/apps/dashboard/src/lib/features/course/components/exercise/submissions/submissions.svelte index a1c95fd4..c8a985d9 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/submissions/submissions.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/submissions/submissions.svelte @@ -8,6 +8,7 @@ import { t } from '$lib/utils/functions/translations'; import type { ExerciseSubmissions } from './types'; import type { SubmissionListItem } from '$features/course/utils/types'; + import { groupSubmissionsByStudentAndAttempt } from '$features/course/utils/exercise-progression-utils'; interface Props { exerciseId: string; @@ -18,6 +19,7 @@ type SubmissionTab = 'summary' | 'individual'; let currentTab = $state('summary'); + const submissionGroups = $derived(groupSubmissionsByStudentAndAttempt(submissionsData)); function normalizeSubmissions(items: SubmissionListItem[]): ExerciseSubmissions[] { return items.map( @@ -66,7 +68,7 @@ - + {:else} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte b/apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte index e063da41..cbfcc8ee 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte @@ -40,9 +40,9 @@ (): ExerciseQuestionLabels => ({ 'submission.common.no_answer': $t('course.navItem.lessons.exercises.all_exercises.analytics.individual.no'), 'submission.common.other': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.other'), - 'submission.chart.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'), + 'submission.chart.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.attempts'), 'submission.chart.no_data': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'), - 'submission.list.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'), + 'submission.list.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.attempts'), 'submission.list.no_responses': $t( 'course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses' ) 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 6dbd8c97..23e032af 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 @@ -49,6 +49,7 @@ import { toExerciseQuestionModel } from './question-type-utils'; import { getExerciseQuestionLabels } from './question-labels'; import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils'; + import { shouldCompleteExerciseFromSubmission } from '$features/course/utils/exercise-progression-utils'; import axios from 'axios'; import { IconButton } from '@cio/ui/custom/icon-button'; import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'; @@ -80,7 +81,19 @@ const questionLabels = $derived(getExerciseQuestionLabels()); const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title')); - const submissionList = $derived(Array.isArray(mySubmissions) ? mySubmissions : []); + /** Submissions created in this session — the `mySubmissions` prop only refreshes on page load. */ + let localSubmissions = $state([]); + + const submissionList = $derived.by(() => { + const loadedSubmissions = Array.isArray(mySubmissions) ? mySubmissions : []; + const newSubmissions = localSubmissions.filter( + (localSubmission) => !loadedSubmissions.some((loadedSubmission) => loadedSubmission.id === localSubmission.id) + ); + return [...loadedSubmissions, ...newSubmissions]; + }); + + /** Attempt currently shown: -1 means "latest" (e.g. right after submitting). */ + const viewedAttemptIndex = $derived(selectedTryIndex >= 0 ? selectedTryIndex : submissionList.length - 1); function handleStart() { questionnaireMetaData.update((m) => ({ @@ -137,6 +150,8 @@ if (exerciseApi.success && submitResult?.data) { skipHydrateFromSubmissions = false; const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string }; + localSubmissions = [...localSubmissions, sub]; + selectedTryIndex = submissionList.length - 1; if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) { applyMySubmission(sub); } else { @@ -150,14 +165,26 @@ exerciseId })); } - courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true }); + const shouldMarkComplete = shouldCompleteExerciseFromSubmission({ + completionPolicy: $questionnaire.completionPolicy, + passThreshold: $questionnaire.passThreshold, + totalPossiblePoints: totalPossibleGrade, + submission: sub + }); - toggleConfetti(); - setTimeout(toggleConfetti, 3000); + courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: shouldMarkComplete }); + + if (shouldMarkComplete) { + toggleConfetti(); + setTimeout(toggleConfetti, 3000); + } const allContentItems = getOrderedNavigableContent(courseApi.course); const allComplete = - $isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete); + shouldMarkComplete && + $isOrgStudent && + allContentItems.length > 0 && + allContentItems.every((item) => item.isComplete); if (allComplete && courseApi.course?.id) { const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined; @@ -165,11 +192,13 @@ const certRes = await courseApi.getCertificationEvaluation(courseApi.course.id); if (certRes?.data) { - if (certRes.data.isNewCompletion) { - updateCourseCompletionModal(courseApi.course.id, 'eligible', certRes.data, requiredExerciseId); - } else { - updateCourseCompletionModal(courseApi.course.id, 'not-eligible', certRes.data, requiredExerciseId); - } + const hasCompletedCourse = Boolean(certRes.data.eligibleForCertificate || certRes.data.certificateEarnedAt); + updateCourseCompletionModal( + courseApi.course.id, + hasCompletedCourse ? 'eligible' : 'not-eligible', + certRes.data, + requiredExerciseId + ); } else { closeCourseCompletionModal(); } @@ -288,6 +317,12 @@ }, 0); } + function formatPercent(value: number) { + return new Intl.NumberFormat(undefined, { + maximumFractionDigits: 1 + }).format(value); + } + function getAutoSavedData() { isLoadingAutoSavedData = true; @@ -606,6 +641,24 @@ ); const canGoNextForSharedQuestion = $derived(hasAnswerValue(sharedCurrentAnswer)); const sectionQuestionTotalPoints = $derived(getTotalPossibleGrade(currentSectionQuestions)); + const passThresholdPercent = $derived(Number($questionnaire.passThreshold ?? 100)); + const gradedScorePercent = $derived.by(() => { + const finalGrade = Number($questionnaireMetaData.finalTotalGrade); + const totalGrade = Number($questionnaireMetaData.totalPossibleGrade); + if (!Number.isFinite(finalGrade) || !Number.isFinite(totalGrade) || totalGrade <= 0) return null; + + return (finalGrade / totalGrade) * 100; + }); + const shouldShowPassedCompletionResult = $derived( + ($questionnaire.completionPolicy ?? 'submitted') === 'passed' && + STATUS.GRADED === $questionnaireMetaData.status && + gradedScorePercent !== null + ); + const didCurrentAttemptPass = $derived( + shouldShowPassedCompletionResult && gradedScorePercent !== null && gradedScorePercent >= passThresholdPercent + ); + const gradedScorePercentLabel = $derived(gradedScorePercent === null ? '' : formatPercent(gradedScorePercent)); + const passThresholdPercentLabel = $derived(formatPercent(passThresholdPercent)); const sectionNavigationGroups = $derived( activeSections.map((section, sectionIndex) => { const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id); @@ -666,15 +719,15 @@ }); function goPrevTry() { - if (selectedTryIndex <= 0) return; - selectedTryIndex -= 1; + if (viewedAttemptIndex <= 0) return; + selectedTryIndex = viewedAttemptIndex - 1; const sub = submissionList[selectedTryIndex]; if (sub) applyMySubmission(sub); } function goNextTry() { - if (selectedTryIndex >= submissionList.length - 1) return; - selectedTryIndex += 1; + if (viewedAttemptIndex >= submissionList.length - 1) return; + selectedTryIndex = viewedAttemptIndex + 1; const sub = submissionList[selectedTryIndex]; if (sub) applyMySubmission(sub); } @@ -725,6 +778,7 @@ alreadyCheckedAutoSavedData = false; skipHydrateFromSubmissions = false; selectedTryIndex = -1; + localSubmissions = []; } prevExerciseId = exerciseId; }); @@ -842,6 +896,43 @@ {$questionnaireMetaData.totalPossibleGrade}

+ {#if shouldShowPassedCompletionResult} + +
+
+

+ {didCurrentAttemptPass + ? $t('course.navItem.lessons.exercises.all_exercises.view_mode.passed_completion_result_title') + : $t( + 'course.navItem.lessons.exercises.all_exercises.view_mode.not_passed_completion_result_title' + )} +

+

+ {didCurrentAttemptPass + ? $t( + 'course.navItem.lessons.exercises.all_exercises.view_mode.passed_completion_result_description', + { + scorePercent: gradedScorePercentLabel, + requiredPercent: passThresholdPercentLabel + } + ) + : $t( + 'course.navItem.lessons.exercises.all_exercises.view_mode.not_passed_completion_result_description', + { + scorePercent: gradedScorePercentLabel, + requiredPercent: passThresholdPercentLabel + } + )} +

+
+ {#if $questionnaire.allowMultipleAttempts && !didCurrentAttemptPass} + + {/if} +
+
+ {/if} {:else if isSelfPacedLikeCourse(courseApi.course?.type)} @@ -861,7 +952,7 @@ {#if submissionList.length > 1}
@@ -869,12 +960,12 @@ {$t('course.navItem.lessons.exercises.all_exercises.view_mode.attempt_counter', { - current: selectedTryIndex + 1, + current: viewedAttemptIndex + 1, total: submissionList.length })} = submissionList.length - 1} + disabled={viewedAttemptIndex >= submissionList.length - 1} onclick={goNextTry} tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.next_try')} > @@ -894,7 +985,7 @@ /> - {#if $questionnaire.allowMultipleAttempts} + {#if $questionnaire.allowMultipleAttempts && !shouldShowPassedCompletionResult}