diff --git a/apps/api/src/services/submission/submission.ts b/apps/api/src/services/submission/submission.ts index a222c568..e484c122 100644 --- a/apps/api/src/services/submission/submission.ts +++ b/apps/api/src/services/submission/submission.ts @@ -1,4 +1,5 @@ import type { AnswerData, FileUploadAnswerData } from '@cio/question-types'; +import { scoreSubmissionAnswers, type ScoreSubmissionResult, validateTextareaAnswer } from '@cio/question-types'; import { AppError, ErrorCodes } from '@api/utils/errors'; import type { TNewSubmission, TSubmission } from '@cio/db/types'; import type { @@ -242,6 +243,7 @@ export async function listSubmissionsByExercise( s.answers?.length ? { ...s, answers: await enrichFileUploadAnswersArray(s.answers) } : s ) ); + enriched.sort((a, b) => new Date(a.createdAt ?? 0).getTime() - new Date(b.createdAt ?? 0).getTime()); return enriched; } catch (error) { throw new AppError( @@ -464,16 +466,24 @@ export async function listSubmissionsForGrading(courseId: string) { * @param exerciseId Exercise ID * @param submittedBy Group member ID * @param answers Array of question answers - * @returns Created submission + * @returns Created submission (with `answers` when auto-graded on the server) */ export async function createSubmissionService( courseId: string, exerciseId: string, submittedBy: string, answers: Array<{ questionId: number; optionId?: number; answer?: string }> -): Promise { +): Promise { try { const exerciseWithRelations = await getExerciseWithRelationsOptimized(exerciseId); + + if (!exerciseWithRelations.exercise.allowMultipleAttempts) { + const existing = await getSubmissionsByCourseIdWithDetails(courseId, exerciseId, submittedBy); + if (existing.length > 0) { + throw new AppError('This exercise allows only one submission', ErrorCodes.VALIDATION_ERROR, 400); + } + } + const overallStatus = resolveOverallStatusFromTypenames( exerciseWithRelations.questions .map((question) => question.questionType?.typename ?? '') @@ -491,30 +501,37 @@ export async function createSubmissionService( total: 0 // Will be calculated during grading }; - const submission = await createSubmission(submissionData); + let submission = await createSubmission(submissionData); + + const questionById = new Map( + exerciseWithRelations.questions.map((q) => { + const questionTypeKey = QUESTION_TYPE_ID_TO_KEY[q.questionTypeId] ?? 'TEXTAREA'; + return [ + q.id, + { + id: q.id, + title: String(q.title ?? ''), + questionType: questionTypeKey, + points: Number(q.points ?? 0), + settings: + q.settings && typeof q.settings === 'object' && !Array.isArray(q.settings) + ? (q.settings as Record) + : {}, + options: (q.options ?? []).map((o) => ({ + id: o.id, + label: o.label ?? '', + value: o.value ?? undefined, + isCorrect: o.isCorrect + })) + } + ]; + }) + ); + + const answerByQuestionId = new Map(); // Create question answers if (answers.length > 0) { - const questionById = new Map( - exerciseWithRelations.questions.map((q) => { - const questionTypeKey = QUESTION_TYPE_ID_TO_KEY[q.questionTypeId] ?? 'TEXTAREA'; - return [ - q.id, - { - id: q.id, - title: q.title ?? '', - questionType: questionTypeKey, - options: (q.options ?? []).map((o) => ({ - id: o.id, - label: o.label ?? '', - value: o.value ?? undefined, - isCorrect: o.isCorrect - })) - } - ]; - }) - ); - await db.transaction(async () => { for (const ans of answers) { const question = questionById.get(ans.questionId); @@ -527,6 +544,24 @@ export async function createSubmissionService( ); if (!answerData) continue; + if (answerData.type === 'TEXTAREA') { + const validation = validateTextareaAnswer(answerData.text, question); + + if (!validation.isValid) { + throw new AppError( + validation.minCharacters !== undefined && validation.maxCharacters !== undefined + ? `Paragraph answer must be between ${validation.minCharacters} and ${validation.maxCharacters} characters` + : validation.reason === 'below_min' && validation.minCharacters !== undefined + ? `Paragraph answer must be at least ${validation.minCharacters} characters` + : `Paragraph answer must be at most ${validation.maxCharacters} characters`, + ErrorCodes.VALIDATION_ERROR, + 400 + ); + } + } + + answerByQuestionId.set(ans.questionId, answerData); + await upsertQuestionAnswer({ submissionId: submission.id, questionId: ans.questionId, @@ -537,6 +572,53 @@ export async function createSubmissionService( }); } + const courseRows = await getCourseById(courseId); + const course = courseRows[0]; + const shouldAutoGrade = + course?.type === 'SELF_PACED' && overallStatus === 'auto_graded' && exerciseWithRelations.questions.length > 0; + + if (shouldAutoGrade) { + const answeredIds = new Set(answerByQuestionId.keys()); + for (const q of exerciseWithRelations.questions) { + if (q.id == null) continue; + if (!answeredIds.has(q.id)) { + await upsertQuestionAnswer({ + submissionId: submission.id, + questionId: q.id, + groupMemberId: submittedBy, + answerData: null + }); + } + } + + const dbQuestions = exerciseWithRelations.questions + .filter((q): q is typeof q & { id: number } => q.id != null) + .map((q) => ({ + id: q.id, + title: q.title, + questionTypeId: q.questionTypeId, + points: q.points != null ? Number(q.points) : 0, + settings: (q.settings as Record) ?? {}, + options: q.options ?? [] + })); + + const { scores, total }: ScoreSubmissionResult = scoreSubmissionAnswers(dbQuestions, answerByQuestionId); + + const graded = await updateSubmissionGrades(submission.id, { + answers: scores.map((s: ScoreSubmissionResult['scores'][number]) => ({ + questionId: s.questionId, + points: s.points + })), + total, + statusId: 3, + gradingState: 'completed' + }); + + if (graded) { + submission = graded; + } + } + // Send email notification to teachers try { await sendExerciseSubmissionUpdateEmail(courseId, exerciseId, submittedBy); @@ -545,6 +627,10 @@ export async function createSubmissionService( console.error('Failed to send exercise submission update email:', emailError); } + if (submission.gradingState === 'completed') { + return await getSubmission(submission.id); + } + return submission; } catch (error) { if (error instanceof AppError) { diff --git a/apps/dashboard/src/lib/features/course/components/exercise/question-labels.ts b/apps/dashboard/src/lib/features/course/components/exercise/question-labels.ts index c8af5d39..6204bf12 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/question-labels.ts +++ b/apps/dashboard/src/lib/features/course/components/exercise/question-labels.ts @@ -291,6 +291,18 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels { 'textarea.edit.placeholder': t.get( 'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.edit.placeholder' ), + 'textarea.edit.min_characters_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.edit.min_characters_label' + ), + 'textarea.edit.min_characters_placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.edit.min_characters_placeholder' + ), + 'textarea.edit.max_characters_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.edit.max_characters_label' + ), + 'textarea.edit.max_characters_placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.edit.max_characters_placeholder' + ), 'textarea.take.placeholder': t.get( 'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.placeholder' ), 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 9f117d3c..fa067808 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 @@ -4,7 +4,7 @@ import { fly } from 'svelte/transition'; import { cubicInOut } from 'svelte/easing'; import { courseApi } from '$features/course/api'; - import { questionnaire, questionnaireMetaData } from './store'; + import { questionnaire, questionnaireMetaData, resetStudentExerciseTake } from './store'; import Preview from './preview.svelte'; import { ExerciseQuestion } from '@cio/ui'; import * as Alert from '@cio/ui/base/alert'; @@ -18,34 +18,49 @@ import { STATUS } from './constants'; import { filterOutDeleted } from './functions'; import { formatAnswers } from '$features/course/utils/functions'; - import { toApiPayload, type AnswerData } from '@cio/question-types'; + import { + getTextareaAnswerCharacterCount, + toApiPayload, + type AnswerData, + validateTextareaAnswer + } from '@cio/question-types'; import { exerciseApi, presignApi } from '$features/course/api'; import type { SubmissionListItem } from '$features/course/utils/types'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content'; import { t } from '$lib/utils/functions/translations'; import { snackbar } from '$features/ui/snackbar/store'; + import { globalStore } from '$lib/utils/store/app'; + import { openCourseCompletionModal } from '$features/course/store/course-completion-modal'; import { ContentType } from '@cio/utils/constants/content'; import { toExerciseQuestionModel } from './question-type-utils'; import { getExerciseQuestionLabels } from './question-labels'; import axios from 'axios'; + import { IconButton } from '@cio/ui/custom/icon-button'; + import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'; + import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'; interface Props { preview?: boolean; exerciseId?: string; isFetchingExercise?: boolean; - /** Current user's submission from server (no client fetch) */ - mySubmission?: SubmissionListItem | null; + /** Current user's submissions (ordered by attempt) */ + mySubmissions?: SubmissionListItem[]; } - let { preview = false, exerciseId = '', isFetchingExercise = false, mySubmission = null }: Props = $props(); + let { preview = false, exerciseId = '', isFetchingExercise = false, mySubmissions = [] }: Props = $props(); let isSubmitting = $state(false); + /** When true, do not re-apply a past submission over a fresh take (Try again). */ + let skipHydrateFromSubmissions = $state(false); let isLoadingAutoSavedData = $state(false); let alreadyCheckedAutoSavedData = $state(false); let prevExerciseId = $state(''); let slideDirection = $state<'next' | 'prev'>('next'); + let selectedTryIndex = $state(-1); const questionLabels = $derived(getExerciseQuestionLabels()); + const submissionList = $derived(Array.isArray(mySubmissions) ? mySubmissions : []); + function handleStart() { questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex + 1 })); } @@ -116,20 +131,39 @@ return; } - await exerciseApi.submit(courseApi.course?.id!, exerciseId, answersForApi); + const submitResult = await exerciseApi.submit(courseApi.course.id, exerciseId, answersForApi); - if (exerciseApi.success) { - questionnaireMetaData.update((m) => ({ - ...m, - status: 1, - totalPossibleGrade, - grades: {}, - comment: '', - isFinished: true, - exerciseId - })); + if (exerciseApi.success && submitResult?.data) { + skipHydrateFromSubmissions = false; + const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string }; + if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) { + applyMySubmission(sub); + } else { + questionnaireMetaData.update((m) => ({ + ...m, + status: 1, + totalPossibleGrade, + grades: {}, + comment: '', + isFinished: true, + exerciseId + })); + } courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true }); + if ( + $globalStore.isStudent && + courseApi.course?.id && + submitResult?.data && + 'certification' in submitResult.data + ) { + const certification = (submitResult.data as { certification?: { isNewCompletion?: boolean } | null }) + .certification; + if (certification?.isNewCompletion) { + openCourseCompletionModal(courseApi.course.id); + } + } } + isSubmitting = false; } else { // Advance to next question @@ -208,6 +242,7 @@ case 'CHECKBOX': return answerValue.optionIds.length > 0; case 'TEXTAREA': + return getTextareaAnswerCharacterCount(answerValue.text) > 0; case 'SHORT_ANSWER': return answerValue.text.trim().length > 0; case 'FILL_BLANK': @@ -249,6 +284,12 @@ return; } + const textareaError = getTextareaValidationError(valueToUse); + if (textareaError) { + snackbar.error(textareaError); + return; + } + onSubmit(sharedCurrentQuestionKey, valueToUse); } @@ -256,7 +297,7 @@ if (e.key !== 'Enter' || isSubmitting || !currentQuestion) return; const target = e.target as HTMLElement; - if (target?.tagName === 'TEXTAREA') return; + if (target?.tagName === 'TEXTAREA' || isTextEditorTarget(target)) return; e.preventDefault(); @@ -283,6 +324,42 @@ onSharedNext(valueToUse); } + function isTextEditorTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + + if (target.isContentEditable) return true; + + return target.closest('[contenteditable="true"], .ProseMirror') !== null; + } + + function getTextareaValidationError(answerValue: AnswerData | null | undefined): string | null { + if (!sharedQuestionModel || !answerValue || answerValue.type !== 'TEXTAREA') return null; + + const validation = validateTextareaAnswer(answerValue.text, sharedQuestionModel); + if (validation.isValid) return null; + + if (validation.minCharacters !== undefined && validation.maxCharacters !== undefined) { + return t.get('course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.range_error', { + min: validation.minCharacters, + max: validation.maxCharacters + }); + } + + if (validation.reason === 'below_min' && validation.minCharacters !== undefined) { + return t.get('course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.min_error', { + min: validation.minCharacters + }); + } + + if (validation.reason === 'above_max' && validation.maxCharacters !== undefined) { + return t.get('course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.max_error', { + max: validation.maxCharacters + }); + } + + return null; + } + $effect(() => { if (!alreadyCheckedAutoSavedData) { getAutoSavedData(); @@ -322,10 +399,39 @@ }); $effect(() => { - if (isFetchingExercise || !mySubmission || $questionnaireMetaData.isFinished) return; - applyMySubmission(mySubmission); + if (!exerciseId || isFetchingExercise || skipHydrateFromSubmissions) return; + if (!submissionList.length || $questionnaireMetaData.isFinished) return; + let idx = selectedTryIndex; + if (idx < 0 || idx >= submissionList.length) { + idx = submissionList.length - 1; + selectedTryIndex = idx; + } + const sub = submissionList[idx]; + if (sub) applyMySubmission(sub); }); + function goPrevTry() { + if (selectedTryIndex <= 0) return; + selectedTryIndex -= 1; + const sub = submissionList[selectedTryIndex]; + if (sub) applyMySubmission(sub); + } + + function goNextTry() { + if (selectedTryIndex >= submissionList.length - 1) return; + selectedTryIndex += 1; + const sub = submissionList[selectedTryIndex]; + if (sub) applyMySubmission(sub); + } + + function tryAgain() { + if (!$questionnaire.allowMultipleAttempts) return; + localStorage.removeItem(`autosave-exercise-${exerciseId}`); + selectedTryIndex = -1; + skipHydrateFromSubmissions = true; + resetStudentExerciseTake(); + } + function applyMySubmission(submission: SubmissionListItem) { const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions); let finalTotalGrade = 0; @@ -360,6 +466,8 @@ if (!!prevExerciseId) { isSubmitting = false; alreadyCheckedAutoSavedData = false; + skipHydrateFromSubmissions = false; + selectedTryIndex = -1; } prevExerciseId = exerciseId; }); @@ -474,12 +582,47 @@ {/if} + {#if submissionList.length > 1} +
+ + + + + {$t('course.navItem.lessons.exercises.all_exercises.view_mode.attempt_counter', { + current: selectedTryIndex + 1, + total: submissionList.length + })} + + = submissionList.length - 1} + onclick={goNextTry} + tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.next_try')} + > + + +
+ {/if} + a.order - b.order)} questionnaireMetaData={$questionnaireMetaData} grades={$questionnaireMetaData.grades} disableGrading={true} /> + + + {#if $questionnaire.allowMultipleAttempts} +
+ +
+ {/if} +
{/if} {:else if currentQuestion && currentQuestion?.id}
diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index faa9aba7..ed89d2fa 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -520,7 +520,7 @@ "send_email": "Send invitationsmail til importerede brugere", "submit": "Importer brugere", "back": "Tilbage til Publikum", - "snackbar_success": "Importerede {importerede} brugere ({skipped} eksisterede allerede, {emailsSent} e-mails sendt)", + "snackbar_success": "{imported} nye invitationer, {assigned} eksisterende studerende med kursusadgang, {pendingInvitesRenewed} afventende invitationer fornyet ({emailsSent} e-mails sendt)", "snackbar_no_emails": "Indtast venligst mindst én e-mailadresse", "snackbar_failed": "Kunne ikke importere brugere" } @@ -1164,10 +1164,17 @@ "shared_question": { "textarea": { "take": { - "placeholder": "Skriv dit svar" + "placeholder": "Skriv dit svar", + "min_error": "Answer must be at least {min} characters.", + "max_error": "Answer must be at most {max} characters.", + "range_error": "Answer must be between {min} and {max} characters." }, "edit": { - "placeholder": "Eleverne vil svare med lang tekst" + "placeholder": "Eleverne vil svare med lang tekst", + "min_characters_label": "Minimum characters", + "min_characters_placeholder": "Minimum characters", + "max_characters_label": "Maximum characters", + "max_characters_placeholder": "Maximum characters" } }, "hotspot": { diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index 062026eb..c4d2f552 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -520,7 +520,7 @@ "send_email": "Senden Sie Einladungs-E-Mails an importierte Benutzer", "submit": "Benutzer importieren", "back": "Zurück zum Publikum", - "snackbar_success": "Importierte {imported} Benutzer ({skipped} existierte bereits, {emailsSent} E-Mails wurden gesendet)", + "snackbar_success": "{imported} neue Einladungen, {assigned} bestehende Studierende mit Kurszugang, {pendingInvitesRenewed} ausstehende Einladungen erneuert ({emailsSent} E-Mails gesendet)", "snackbar_no_emails": "Bitte geben Sie mindestens eine E-Mail-Adresse ein", "snackbar_failed": "Benutzer konnten nicht importiert werden" } @@ -1164,10 +1164,17 @@ "shared_question": { "textarea": { "take": { - "placeholder": "Schreiben Sie Ihre Antwort" + "placeholder": "Schreiben Sie Ihre Antwort", + "min_error": "Answer must be at least {min} characters.", + "max_error": "Answer must be at most {max} characters.", + "range_error": "Answer must be between {min} and {max} characters." }, "edit": { - "placeholder": "Die Lernenden antworten mit einem langen Text" + "placeholder": "Die Lernenden antworten mit einem langen Text", + "min_characters_label": "Minimum characters", + "min_characters_placeholder": "Minimum characters", + "max_characters_label": "Maximum characters", + "max_characters_placeholder": "Maximum characters" } }, "hotspot": { diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index 3528e103..197e0c7f 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -983,6 +983,18 @@ "deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.", "threshold_label": "Minimum completion (%)", "threshold_helper": "Combined lesson and exercise progress required to earn the certificate (0–100). Default is 100%.", + "final_exercise_label": "Final exercise (optional)", + "final_exercise_none": "None — no extra exercise score", + "final_exercise_helper": "When set, students must reach at least the minimum score on this exercise (best attempt counts) in addition to the completion percentage above.", + "min_exercise_score_label": "Minimum score on final exercise (%)", + "min_exercise_score_helper": "Score is a percentage of the total points possible on that exercise (0–100).", + "blocker_progress": "Your course progress is {current}%; {required}% is required.", + "blocker_deadline": "The certification deadline has passed.", + "blocker_no_content": "This course has no lessons or exercises to complete.", + "blocker_final_not_submitted": "Submit the required exercise \"{exerciseTitle}\" to qualify for the certificate.", + "blocker_final_pending": "Your attempt on \"{exerciseTitle}\" is still being graded.", + "blocker_final_score": "Your best score on \"{exerciseTitle}\" is {bestPercent}%; {requiredPercent}% is required.", + "blocker_final_misconfigured": "The certification exercise \"{exerciseTitle}\" has no points configured; ask your instructor to fix it.", "email_message_label": "Certification email message", "email_message_helper": "Optional message shown in the email when a student meets the requirements. A link to the certificate page is always included.", "email_message_placeholder": "e.g. We are proud of your progress — keep learning!" @@ -1268,6 +1280,10 @@ "all_exercises": { "heading": "All Exercise", "questions": "Questions", + "settings": "Settings", + "settings_allow_multiple": "Allow multiple attempts", + "settings_allow_multiple_helper": "Students can submit more than once and review past attempts on the results screen.", + "settings_save": "Save settings", "submissions": "Submissions", "save": "Save", "preview": "Preview", @@ -1340,6 +1356,10 @@ } }, "view_mode": { + "attempt_counter": "Attempt {current} of {total}", + "previous_try": "Previous attempt", + "next_try": "Next attempt", + "try_again": "Try again", "no_question": "No question added for this exercise", "empty_description": "Click the Edit button to add.", "empty_edit_hint_prefix": "Click the ", @@ -1578,11 +1598,18 @@ } }, "textarea": { - "edit": { - "placeholder": "Learners will answer with long-form text" - }, "take": { - "placeholder": "Write your answer" + "placeholder": "Write your answer", + "min_error": "Answer must be at least {min} characters.", + "max_error": "Answer must be at most {max} characters.", + "range_error": "Answer must be between {min} and {max} characters." + }, + "edit": { + "placeholder": "Learners will answer with long-form text", + "min_characters_label": "Minimum characters", + "min_characters_placeholder": "Minimum characters", + "max_characters_label": "Maximum characters", + "max_characters_placeholder": "Maximum characters" } } } @@ -2096,7 +2123,7 @@ "send_email": "Send invitation email to imported users", "submit": "Import Users", "back": "Back to Audience", - "snackbar_success": "Imported {imported} users ({skipped} already existed, {emailsSent} emails sent)", + "snackbar_success": "{imported} new invites, {assigned} existing students given course access, {pendingInvitesRenewed} pending invites renewed ({emailsSent} emails sent)", "snackbar_no_emails": "Please enter at least one email address", "snackbar_failed": "Failed to import users" }, diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index 26b4bfc4..9362ce3b 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -520,7 +520,7 @@ "send_email": "Enviar correo electrónico de invitación a usuarios importados", "submit": "Importar usuarios", "back": "Volver a la audiencia", - "snackbar_success": "Usuarios importados {importados} ({skipped} ya existía, {emailsSent} correos electrónicos enviados)", + "snackbar_success": "{imported} invitaciones nuevas, {assigned} estudiantes existentes con acceso al curso, {pendingInvitesRenewed} invitaciones pendientes renovadas ({emailsSent} correos enviados)", "snackbar_no_emails": "Por favor ingrese al menos una dirección de correo electrónico", "snackbar_failed": "No se pudieron importar usuarios" } @@ -1164,10 +1164,17 @@ "shared_question": { "textarea": { "take": { - "placeholder": "Escribe tu respuesta" + "placeholder": "Escribe tu respuesta", + "min_error": "Answer must be at least {min} characters.", + "max_error": "Answer must be at most {max} characters.", + "range_error": "Answer must be between {min} and {max} characters." }, "edit": { - "placeholder": "Los alumnos responderán con un texto extenso." + "placeholder": "Los alumnos responderán con un texto extenso.", + "min_characters_label": "Minimum characters", + "min_characters_placeholder": "Minimum characters", + "max_characters_label": "Maximum characters", + "max_characters_placeholder": "Maximum characters" } }, "hotspot": { diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 4c7d4ffa..bde79151 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -520,7 +520,7 @@ "send_email": "Envoyer un e-mail d'invitation aux utilisateurs importés", "submit": "Importer des utilisateurs", "back": "Retour au public", - "snackbar_success": "Utilisateurs {importés} importés ({skipped} existait déjà, {emailsSent} e-mails envoyés)", + "snackbar_success": "{imported} nouvelles invitations, {assigned} étudiants existants avec accès au cours, {pendingInvitesRenewed} invitations en attente renouvelées ({emailsSent} e-mails envoyés)", "snackbar_no_emails": "Veuillez saisir au moins une adresse e-mail", "snackbar_failed": "Échec de l'importation des utilisateurs" } @@ -1164,10 +1164,17 @@ "shared_question": { "textarea": { "take": { - "placeholder": "Écrivez votre réponse" + "placeholder": "Écrivez votre réponse", + "min_error": "Answer must be at least {min} characters.", + "max_error": "Answer must be at most {max} characters.", + "range_error": "Answer must be between {min} and {max} characters." }, "edit": { - "placeholder": "Les apprenants répondront avec un texte long" + "placeholder": "Les apprenants répondront avec un texte long", + "min_characters_label": "Minimum characters", + "min_characters_placeholder": "Minimum characters", + "max_characters_label": "Maximum characters", + "max_characters_placeholder": "Maximum characters" } }, "hotspot": { diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 43fc0d80..f8b607bc 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -520,7 +520,7 @@ "send_email": "आयातित उपयोगकर्ताओं को आमंत्रण ईमेल भेजें", "submit": "उपयोगकर्ता आयात करें", "back": "दर्शकों के पास वापस जाएँ", - "snackbar_success": "आयातित {आयातित} उपयोगकर्ता ({स्किप्ड} पहले से मौजूद थे, {ईमेल्ससेंट} ईमेल भेजे गए)", + "snackbar_success": "{imported} नए निमंत्रण, {assigned} मौजूदा छात्रों को पाठ्यक्रम पहुंच, {pendingInvitesRenewed} लंबित निमंत्रण नवीकृत ({emailsSent} ईमेल भेजे गए)", "snackbar_no_emails": "कृपया कम से कम एक ईमेल पता दर्ज करें", "snackbar_failed": "उपयोगकर्ताओं को आयात करने में विफल" } @@ -1164,10 +1164,17 @@ "shared_question": { "textarea": { "take": { - "placeholder": "अपना उत्तर लिखें" + "placeholder": "अपना उत्तर लिखें", + "min_error": "Answer must be at least {min} characters.", + "max_error": "Answer must be at most {max} characters.", + "range_error": "Answer must be between {min} and {max} characters." }, "edit": { - "placeholder": "शिक्षार्थी लंबे प्रारूप वाले पाठ के साथ उत्तर देंगे" + "placeholder": "शिक्षार्थी लंबे प्रारूप वाले पाठ के साथ उत्तर देंगे", + "min_characters_label": "Minimum characters", + "min_characters_placeholder": "Minimum characters", + "max_characters_label": "Maximum characters", + "max_characters_placeholder": "Maximum characters" } }, "hotspot": { diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 3a118319..acb57053 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -520,7 +520,7 @@ "send_email": "Wyślij wiadomość e-mail z zaproszeniem do zaimportowanych użytkowników", "submit": "Importuj użytkowników", "back": "Powrót do Publiczności", - "snackbar_success": "Zaimportowani użytkownicy: {imported} ({skipped} już istniało, wysłano e-maile: {emailsSent})", + "snackbar_success": "{imported} nowych zaproszeń, {assigned} istniejących studentów z dostępem do kursu, {pendingInvitesRenewed} odnowionych oczekujących zaproszeń (wysłano e-maile: {emailsSent})", "snackbar_no_emails": "Proszę podać przynajmniej jeden adres e-mail", "snackbar_failed": "Nie udało się zaimportować użytkowników" } @@ -1164,10 +1164,17 @@ "shared_question": { "textarea": { "take": { - "placeholder": "Napisz swoją odpowiedź" + "placeholder": "Napisz swoją odpowiedź", + "min_error": "Answer must be at least {min} characters.", + "max_error": "Answer must be at most {max} characters.", + "range_error": "Answer must be between {min} and {max} characters." }, "edit": { - "placeholder": "Uczniowie będą odpowiadać za pomocą długiego tekstu" + "placeholder": "Uczniowie będą odpowiadać za pomocą długiego tekstu", + "min_characters_label": "Minimum characters", + "min_characters_placeholder": "Minimum characters", + "max_characters_label": "Maximum characters", + "max_characters_placeholder": "Maximum characters" } }, "hotspot": { diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index 4f2402f2..fd18f828 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -520,7 +520,7 @@ "send_email": "Enviar e-mail de convite para usuários importados", "submit": "Importar usuários", "back": "Voltar ao público", - "snackbar_success": "Usuários {importados} importados ({skipped} já existia, {emailsSent} emails enviados)", + "snackbar_success": "{imported} novos convites, {assigned} estudantes existentes com acesso ao curso, {pendingInvitesRenewed} convites pendentes renovados ({emailsSent} e-mails enviados)", "snackbar_no_emails": "Insira pelo menos um endereço de e-mail", "snackbar_failed": "Falha ao importar usuários" } @@ -1164,10 +1164,17 @@ "shared_question": { "textarea": { "take": { - "placeholder": "Escreva sua resposta" + "placeholder": "Escreva sua resposta", + "min_error": "Answer must be at least {min} characters.", + "max_error": "Answer must be at most {max} characters.", + "range_error": "Answer must be between {min} and {max} characters." }, "edit": { - "placeholder": "Os alunos responderão com texto longo" + "placeholder": "Os alunos responderão com texto longo", + "min_characters_label": "Minimum characters", + "min_characters_placeholder": "Minimum characters", + "max_characters_label": "Maximum characters", + "max_characters_placeholder": "Maximum characters" } }, "hotspot": { diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index 6feff52b..85283b5d 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -520,7 +520,7 @@ "send_email": "Отправить электронное письмо с приглашением импортированным пользователям", "submit": "Импортировать пользователей", "back": "Вернуться к аудитории", - "snackbar_success": "Импортированные пользователи: {imported} ({skiped} уже существует, писем отправлено: {emailsSent})", + "snackbar_success": "{imported} новых приглашений, {assigned} существующих студентов с доступом к курсу, {pendingInvitesRenewed} обновлённых ожидающих приглашений (отправлено писем: {emailsSent})", "snackbar_no_emails": "Пожалуйста, введите хотя бы один адрес электронной почты", "snackbar_failed": "Не удалось импортировать пользователей." } @@ -1164,10 +1164,17 @@ "shared_question": { "textarea": { "take": { - "placeholder": "Напишите свой ответ" + "placeholder": "Напишите свой ответ", + "min_error": "Answer must be at least {min} characters.", + "max_error": "Answer must be at most {max} characters.", + "range_error": "Answer must be between {min} and {max} characters." }, "edit": { - "placeholder": "Учащиеся будут отвечать длинным текстом" + "placeholder": "Учащиеся будут отвечать длинным текстом", + "min_characters_label": "Minimum characters", + "min_characters_placeholder": "Minimum characters", + "max_characters_label": "Maximum characters", + "max_characters_placeholder": "Maximum characters" } }, "hotspot": { diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 1f249a69..3c5edbc9 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -520,7 +520,7 @@ "send_email": "Gửi email lời mời tới người dùng đã nhập", "submit": "Nhập người dùng", "back": "Quay lại Đối tượng", - "snackbar_success": "Đã nhập {imported} người dùng ({skipped} đã tồn tại, đã gửi email {emailsSent})", + "snackbar_success": "{imported} lời mời mới, {assigned} học viên hiện có được quyền truy cập khóa học, {pendingInvitesRenewed} lời mời đang chờ được gia hạn ({emailsSent} email đã gửi)", "snackbar_no_emails": "Vui lòng nhập ít nhất một địa chỉ email", "snackbar_failed": "Không thể nhập người dùng" } @@ -1164,10 +1164,17 @@ "shared_question": { "textarea": { "take": { - "placeholder": "Viết câu trả lời của bạn" + "placeholder": "Viết câu trả lời của bạn", + "min_error": "Answer must be at least {min} characters.", + "max_error": "Answer must be at most {max} characters.", + "range_error": "Answer must be between {min} and {max} characters." }, "edit": { - "placeholder": "Người học sẽ trả lời bằng văn bản dài" + "placeholder": "Người học sẽ trả lời bằng văn bản dài", + "min_characters_label": "Minimum characters", + "min_characters_placeholder": "Minimum characters", + "max_characters_label": "Maximum characters", + "max_characters_placeholder": "Maximum characters" } }, "hotspot": { diff --git a/packages/db/src/drizzle.ts b/packages/db/src/drizzle.ts index a2028c6f..a222b023 100644 --- a/packages/db/src/drizzle.ts +++ b/packages/db/src/drizzle.ts @@ -1,14 +1,40 @@ import 'dotenv/config'; import { SQL, sql } from 'drizzle-orm'; - import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres, { type Sql } from 'postgres'; // import * as schema from './schema'; const connectionString = process.env.DATABASE_URL ?? process.env.PRIVATE_DATABASE_URL ?? ''; -export const db = drizzle(connectionString); +type DatabaseClient = ReturnType; + +const globalDatabase = globalThis as typeof globalThis & { + __cioDatabaseClient?: Sql; + __cioDb?: DatabaseClient; +}; + +function createDatabaseClient() { + return postgres(connectionString, { + max: process.env.NODE_ENV === 'production' ? 10 : 1, + idle_timeout: 20, + connect_timeout: 10, + max_lifetime: 60 * 30 + }); +} + +const databaseClient = globalDatabase.__cioDatabaseClient ?? createDatabaseClient(); + +if (process.env.NODE_ENV !== 'production') { + globalDatabase.__cioDatabaseClient = databaseClient; +} + +export const db = globalDatabase.__cioDb ?? drizzle(databaseClient); + +if (process.env.NODE_ENV !== 'production') { + globalDatabase.__cioDb = db; +} export type DbClient = typeof db; export type TxClient = Parameters[0] extends (tx: infer T) => any ? T : never; diff --git a/packages/question-types/src/exercise-types.ts b/packages/question-types/src/exercise-types.ts index 3d7780e2..e6840560 100644 --- a/packages/question-types/src/exercise-types.ts +++ b/packages/question-types/src/exercise-types.ts @@ -109,6 +109,10 @@ export type ExerciseQuestionLabelKey = | 'hotspot.take.placeholder' | 'hotspot.preview.helper' | 'textarea.edit.placeholder' + | 'textarea.edit.min_characters_label' + | 'textarea.edit.min_characters_placeholder' + | 'textarea.edit.max_characters_label' + | 'textarea.edit.max_characters_placeholder' | 'textarea.take.placeholder' | 'submission.common.no_answer' | 'submission.common.other' diff --git a/packages/question-types/src/index.ts b/packages/question-types/src/index.ts index 97778b12..bb7e3d4d 100644 --- a/packages/question-types/src/index.ts +++ b/packages/question-types/src/index.ts @@ -5,6 +5,7 @@ export * from './question-type-lookup'; export * from './question-type-capabilities'; export * from './question-answer-payload'; export * from './question-scoring-rules'; +export * from './question-scoring'; export * from './exercise-types'; export * from './render-contract'; @@ -12,3 +13,4 @@ export * from './form-state'; export * from './answer-data'; export * from './answer-codecs'; export * from './renderer-registry'; +export * from './textarea-validation'; diff --git a/packages/question-types/src/textarea-validation.ts b/packages/question-types/src/textarea-validation.ts new file mode 100644 index 00000000..a925c2c5 --- /dev/null +++ b/packages/question-types/src/textarea-validation.ts @@ -0,0 +1,113 @@ +import type { ExerciseQuestionModel } from './exercise-types'; + +export interface TextareaCharacterLimits { + minCharacters?: number; + maxCharacters?: number; +} + +export interface TextareaAnswerValidation extends TextareaCharacterLimits { + characterCount: number; + isValid: boolean; + reason: 'below_min' | 'above_max' | null; +} + +type SettingsLike = Pick | Record | null | undefined; + +export function sanitizeTextareaCharacterLimit(value: unknown): number | undefined { + const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN; + + if (!Number.isFinite(parsed) || parsed < 0) return undefined; + + return Math.floor(parsed); +} + +function resolveSettings(input: SettingsLike): Record { + if (!input || typeof input !== 'object') return {}; + + if ('settings' in input && input.settings && typeof input.settings === 'object' && !Array.isArray(input.settings)) { + return input.settings as Record; + } + + return input as Record; +} + +function decodeHtmlEntities(value: string): string { + return value + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/'/gi, "'") + .replace(/&#(\d+);/gi, (_, code) => String.fromCharCode(Number(code))) + .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCharCode(parseInt(code, 16))); +} + +export function getTextareaCharacterLimits(input: SettingsLike): TextareaCharacterLimits { + const settings = resolveSettings(input); + const minCharacters = sanitizeTextareaCharacterLimit(settings.minCharacters); + const rawMaxCharacters = sanitizeTextareaCharacterLimit(settings.maxCharacters); + const maxCharacters = + minCharacters !== undefined && rawMaxCharacters !== undefined && rawMaxCharacters < minCharacters + ? minCharacters + : rawMaxCharacters; + + return { + minCharacters, + maxCharacters + }; +} + +export function getTextareaAnswerText(value: string | null | undefined): string { + const html = String(value ?? ''); + + return decodeHtmlEntities( + html + .replace(//gi, '\n') + .replace(/<\/(p|div|li|blockquote|h[1-6])>/gi, '\n') + .replace(/]*>/gi, '- ') + .replace(/<[^>]*>/g, '') + .replace(/\r\n?/g, '\n') + ).trim(); +} + +export function getTextareaAnswerCharacterCount(value: string | null | undefined): number { + return getTextareaAnswerText(value).length; +} + +export function validateTextareaAnswer( + value: string | null | undefined, + input: SettingsLike +): TextareaAnswerValidation { + const { minCharacters, maxCharacters } = getTextareaCharacterLimits(input); + const characterCount = getTextareaAnswerCharacterCount(value); + + if (minCharacters !== undefined && characterCount < minCharacters) { + return { + characterCount, + minCharacters, + maxCharacters, + isValid: false, + reason: 'below_min' + }; + } + + if (maxCharacters !== undefined && characterCount > maxCharacters) { + return { + characterCount, + minCharacters, + maxCharacters, + isValid: false, + reason: 'above_max' + }; + } + + return { + characterCount, + minCharacters, + maxCharacters, + isValid: true, + reason: null + }; +} diff --git a/packages/question-types/tests/textarea-validation.test.ts b/packages/question-types/tests/textarea-validation.test.ts new file mode 100644 index 00000000..0e971cca --- /dev/null +++ b/packages/question-types/tests/textarea-validation.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { + getTextareaAnswerCharacterCount, + getTextareaAnswerText, + getTextareaCharacterLimits, + sanitizeTextareaCharacterLimit, + validateTextareaAnswer +} from '../src'; + +describe('textarea validation helpers', () => { + it('extracts plain text from rich-text HTML', () => { + expect(getTextareaAnswerText('

Hello world

Again

')).toBe('Hello world\nAgain'); + expect(getTextareaAnswerCharacterCount('

Hello world

Again

')).toBe(17); + }); + + it('normalizes min and max character settings', () => { + expect(getTextareaCharacterLimits({ minCharacters: '10', maxCharacters: '5' })).toEqual({ + minCharacters: 10, + maxCharacters: 10 + }); + }); + + it('sanitizes individual character limit values', () => { + expect(sanitizeTextareaCharacterLimit('12.9')).toBe(12); + expect(sanitizeTextareaCharacterLimit(-1)).toBeUndefined(); + expect(sanitizeTextareaCharacterLimit('abc')).toBeUndefined(); + }); + + it('rejects answers below the minimum', () => { + expect(validateTextareaAnswer('

Short

', { minCharacters: 10 })).toMatchObject({ + characterCount: 5, + isValid: false, + reason: 'below_min', + minCharacters: 10 + }); + }); + + it('rejects answers above the maximum', () => { + expect(validateTextareaAnswer('

This is too long

', { maxCharacters: 4 })).toMatchObject({ + characterCount: 16, + isValid: false, + reason: 'above_max', + maxCharacters: 4 + }); + }); + + it('accepts answers within the configured range', () => { + expect(validateTextareaAnswer('

Just right

', { minCharacters: 5, maxCharacters: 15 })).toMatchObject({ + characterCount: 10, + isValid: true, + reason: null, + minCharacters: 5, + maxCharacters: 15 + }); + }); +}); diff --git a/packages/storybook/src/molecules/exercise-question/question-fixtures.ts b/packages/storybook/src/molecules/exercise-question/question-fixtures.ts index 2a71a37f..ea1841bb 100644 --- a/packages/storybook/src/molecules/exercise-question/question-fixtures.ts +++ b/packages/storybook/src/molecules/exercise-question/question-fixtures.ts @@ -78,7 +78,9 @@ export const TEXTAREA_FIXTURE: QuestionStoryFixture = { title: 'Explain why exercise feedback should be specific. [TEXTAREA]', questionType: 'TEXTAREA', settings: { - description: 'Provide a detailed response.' + description: 'Provide a detailed response.', + minCharacters: 30, + maxCharacters: 250 } }, answer: '

Specific feedback helps students improve on the next attempt quickly.

' diff --git a/packages/storybook/src/molecules/exercise-question/question-labels.ts b/packages/storybook/src/molecules/exercise-question/question-labels.ts index 036aba46..8a230e9d 100644 --- a/packages/storybook/src/molecules/exercise-question/question-labels.ts +++ b/packages/storybook/src/molecules/exercise-question/question-labels.ts @@ -109,6 +109,10 @@ export const QUESTION_LABELS = { 'hotspot.take.placeholder': 'Provide hotspot coordinates as JSON', 'hotspot.preview.helper': 'Hotspot preview is provided by host app image and hotspot settings.', 'textarea.edit.placeholder': 'Learners will answer with long-form text', + 'textarea.edit.min_characters_label': 'Minimum characters', + 'textarea.edit.min_characters_placeholder': 'Minimum characters', + 'textarea.edit.max_characters_label': 'Maximum characters', + 'textarea.edit.max_characters_placeholder': 'Maximum characters', 'textarea.take.placeholder': 'Write your answer', 'submission.common.no_answer': 'No answer', 'submission.common.other': 'Other', diff --git a/packages/ui/src/custom/editor/editor.svelte b/packages/ui/src/custom/editor/editor.svelte index b10c4dc8..1894e6b8 100644 --- a/packages/ui/src/custom/editor/editor.svelte +++ b/packages/ui/src/custom/editor/editor.svelte @@ -49,6 +49,14 @@ // Browser detection const browser = typeof window !== 'undefined'; + function normalizeEditorContent(value: HTMLContent | undefined): string { + const html = String(value ?? '').trim(); + + if (html === '' || html === '

' || html === '


') return ''; + + return html; + } + // Handle content persistence $effect(() => { if (enablePersistence && browser && content) { @@ -94,6 +102,17 @@ } }); + $effect(() => { + if (!editor || editor.isDestroyed) return; + + const nextContent = normalizeEditorContent(content); + const currentContent = normalizeEditorContent(editor.getHTML()); + + if (currentContent === nextContent) return; + + editor.commands.setContent(nextContent, false); + }); + function onUpdate(props: { editor: Editor; transaction: Transaction }) { if (props?.editor && !props.editor.isDestroyed) { const newContent = props.editor.getHTML(); diff --git a/packages/ui/src/custom/exercise-question/renderers/textarea/edit.svelte b/packages/ui/src/custom/exercise-question/renderers/textarea/edit.svelte index 447a9d82..085395ba 100644 --- a/packages/ui/src/custom/exercise-question/renderers/textarea/edit.svelte +++ b/packages/ui/src/custom/exercise-question/renderers/textarea/edit.svelte @@ -1,12 +1,68 @@