Implement textarea validation for character limits in submission service; enhance user feedback with error messages for minimum and maximum character requirements. Update translations and UI components to support new validation features.

This commit is contained in:
Rotimi Best
2026-03-29 09:50:24 +01:00
parent acccbee110
commit ff2ad59377
24 changed files with 805 additions and 88 deletions
+109 -23
View File
@@ -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<TSubmission> {
): Promise<TSubmission & { answers?: any[] }> {
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<string, unknown>)
: {},
options: (q.options ?? []).map((o) => ({
id: o.id,
label: o.label ?? '',
value: o.value ?? undefined,
isCorrect: o.isCorrect
}))
}
];
})
);
const answerByQuestionId = new Map<number, AnswerData>();
// 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<string, unknown>) ?? {},
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) {