From bd9aabdf77a1aef1910944fe952169b68394b16f Mon Sep 17 00:00:00 2001 From: Meenakshi Date: Tue, 14 Apr 2026 09:15:01 +0530 Subject: [PATCH 01/24] Fix: ReDoS vulnerability in URL-only regex validation Replace nested quantifier regex /^(https?:\/\/\S+\s*)+$/ with /^https?:\/\/\S+(\s+https?:\/\/\S+)*\s*$/ in validateQuestionText() to eliminate exponential backtracking (CWE-1333 / ReDoS). Introduced in: 3b8c5d4 (Feat: Crowd Question submission) --- .../services/StudentQuestionService.ts | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 backend/src/modules/studentQuestions/services/StudentQuestionService.ts diff --git a/backend/src/modules/studentQuestions/services/StudentQuestionService.ts b/backend/src/modules/studentQuestions/services/StudentQuestionService.ts new file mode 100644 index 000000000..c75259edb --- /dev/null +++ b/backend/src/modules/studentQuestions/services/StudentQuestionService.ts @@ -0,0 +1,254 @@ +import {inject, injectable} from 'inversify'; +import {ForbiddenError, BadRequestError, NotFoundError} from 'routing-controllers'; +import {STUDENT_QUESTION_TYPES} from '../types.js'; +import {StudentQuestionRepository} from '../repositories/providers/mongodb/StudentQuestionRepository.js'; +import { + IStudentQuestionOption, + StudentQuestionType, + StudentSegmentQuestion, +} from '../classes/transformers/StudentSegmentQuestion.js'; +import {GLOBAL_TYPES} from '#root/types.js'; +import {ISettingRepository} from '#shared/database/index.js'; + +const PROFANITY_LIST = [ + 'fuck', + 'shit', + 'bitch', + 'asshole', + 'bastard', + 'nigger', + 'slut', + 'whore', +]; + +@injectable() +export class StudentQuestionService { + constructor( + @inject(STUDENT_QUESTION_TYPES.StudentQuestionRepo) + private readonly repository: StudentQuestionRepository, + @inject(GLOBAL_TYPES.SettingRepo) + private readonly settingRepo: ISettingRepository, + ) {} + + private normalizeQuestionText(questionText: string): string { + return questionText.trim().replace(/\s+/g, ' ').toLowerCase(); + } + + private validateImageReference(imageUrl: string, fieldName: string): string { + const trimmed = imageUrl.trim(); + + if (!trimmed) { + throw new BadRequestError(`${fieldName} cannot be empty.`); + } + + const isHttpUrl = /^https?:\/\/\S+$/i.test(trimmed); + const isDataUrl = /^data:image\/(png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(trimmed); + + if (!isHttpUrl && !isDataUrl) { + throw new BadRequestError( + `${fieldName} must be a valid image URL or data URL.`, + ); + } + + return trimmed; + } + + private validateQuestionText(questionText: string): string { + const normalized = this.normalizeQuestionText(questionText); + + if (normalized.length < 10 || normalized.length > 300) { + throw new BadRequestError('Question must be between 10 and 300 characters.'); + } + + if (/^https?:\/\/\S+(\s+https?:\/\/\S+)*\s*$/.test(normalized)) { + throw new BadRequestError('Question cannot contain only URLs.'); + } + + if (/(.)\1{7,}/.test(normalized) || /(\b\w+\b)(\s+\1){4,}/.test(normalized)) { + throw new BadRequestError('Question looks like spam. Please rewrite it.'); + } + + if (PROFANITY_LIST.some(word => normalized.includes(word))) { + throw new BadRequestError('Question contains inappropriate language.'); + } + + return normalized; + } + + private validateOption(option: IStudentQuestionOption, index: number): IStudentQuestionOption { + const text = option.text?.trim(); + const imageUrl = option.imageUrl?.trim(); + + if (!text) { + throw new BadRequestError( + `Option ${index + 1} must include text.`, + ); + } + + if (text && text.length > 150) { + throw new BadRequestError( + `Option ${index + 1} text must be 150 characters or fewer.`, + ); + } + + return { + text, + ...(imageUrl + ? {imageUrl: this.validateImageReference(imageUrl, `Option ${index + 1} image`) } + : {}), + }; + } + + private normalizeQuestionSignature(input: { + questionText: string; + questionImageUrl?: string; + options: IStudentQuestionOption[]; + correctOptionIndex: number; + }): string { + const optionSignature = input.options + .map(option => { + const normalizedText = option.text + ? this.normalizeQuestionText(option.text) + : ''; + const normalizedImage = option.imageUrl?.trim().toLowerCase() || ''; + return `${normalizedText}::${normalizedImage}`; + }) + .join('|'); + + return [ + this.normalizeQuestionText(input.questionText), + input.questionImageUrl?.trim().toLowerCase() || '', + optionSignature, + String(input.correctOptionIndex), + ].join('||'); + } + + private async ensureSubmissionEnabled(courseId: string, courseVersionId: string): Promise { + const courseSettings = await this.settingRepo.readCourseSettings(courseId, courseVersionId); + const isEnabled = + courseSettings?.settings?.crowdsourcedQuestionSubmissionEnabled === true; + + if (!isEnabled) { + throw new ForbiddenError('Question submission is not enabled for this course version.'); + } + } + + async createQuestion(input: { + courseId: string; + courseVersionId: string; + segmentId: string; + questionType: StudentQuestionType; + questionText: string; + questionImageUrl?: string; + options: IStudentQuestionOption[]; + correctOptionIndex: number; + createdBy: string; + }): Promise { + await this.ensureSubmissionEnabled(input.courseId, input.courseVersionId); + + if (input.questionType !== 'SELECT_ONE_IN_LOT') { + throw new BadRequestError('Only single-answer MCQ submissions are supported.'); + } + + if (!Array.isArray(input.options) || input.options.length < 2 || input.options.length > 8) { + throw new BadRequestError('MCQ submissions must include between 2 and 8 options.'); + } + + const questionText = input.questionText.trim(); + this.validateQuestionText(questionText); + const questionImageUrl = input.questionImageUrl?.trim() + ? this.validateImageReference(input.questionImageUrl, 'Question image') + : undefined; + const options = input.options.map((option, index) => + this.validateOption(option, index), + ); + + if ( + input.correctOptionIndex < 0 || + input.correctOptionIndex >= options.length + ) { + throw new BadRequestError('Correct option index is out of range.'); + } + + const normalizedQuestionSignature = this.normalizeQuestionSignature({ + questionText, + questionImageUrl, + options, + correctOptionIndex: input.correctOptionIndex, + }); + + const duplicate = await this.repository.findDuplicate({ + courseVersionId: input.courseVersionId, + segmentId: input.segmentId, + normalizedQuestionText: normalizedQuestionSignature, + }); + + if (duplicate) { + throw new BadRequestError( + 'A similar question already exists for this segment.', + ); + } + + const question = new StudentSegmentQuestion({ + courseId: input.courseId, + courseVersionId: input.courseVersionId, + segmentId: input.segmentId, + questionType: input.questionType, + questionText, + questionImageUrl, + options, + correctOptionIndex: input.correctOptionIndex, + normalizedQuestionText: normalizedQuestionSignature, + createdBy: input.createdBy, + }); + + return await this.repository.create(question); + } + + async listSegmentQuestions(input: { + courseId: string; + courseVersionId: string; + segmentId: string; + limit: number; + }) { + return await this.repository.listBySegment(input); + } + + async updateQuestionStatus(input: { + courseId: string; + courseVersionId: string; + segmentId: string; + questionId: string; + status: 'UNVERIFIED' | 'TO_BE_VALIDATED' | 'VALIDATED' | 'REJECTED'; + reviewedBy: string; + reason?: string; + }): Promise { + const allowedStatuses = ['UNVERIFIED', 'TO_BE_VALIDATED', 'VALIDATED', 'REJECTED']; + if (!allowedStatuses.includes(input.status)) { + throw new BadRequestError('Invalid student question status.'); + } + + if (input.status === 'REJECTED') { + const reason = input.reason?.trim(); + if (!reason || reason.length < 3) { + throw new BadRequestError('A rejection reason of at least 3 characters is required.'); + } + if (reason.length > 500) { + throw new BadRequestError('Rejection reason must be 500 characters or fewer.'); + } + } + + const updated = await this.repository.updateStatus({ + courseId: input.courseId, + courseVersionId: input.courseVersionId, + segmentId: input.segmentId, + questionId: input.questionId, + status: input.status, + reviewedBy: input.reviewedBy, + rejectionReason: input.reason, + }); + if (!updated) { + throw new NotFoundError('Student question not found for the given segment.'); + } + } +} From efb11bdbeceb3b6bfa01105f4cba3066bc227982 Mon Sep 17 00:00:00 2001 From: Meenakshi <45432051+MeenakshiArunsankar@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:07:30 +0530 Subject: [PATCH 02/24] Fixing course auto approval alert and notification to the learner (#923) Co-authored-by: Nandan Prabhudesai --- .../src/app/pages/student/CourseRegistration.tsx | 15 ++++++++++++--- frontend/src/hooks/hooks.ts | 10 ++++++++-- frontend/src/layouts/student-layout.tsx | 6 +++--- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/pages/student/CourseRegistration.tsx b/frontend/src/app/pages/student/CourseRegistration.tsx index f695919c9..3a2dab51c 100644 --- a/frontend/src/app/pages/student/CourseRegistration.tsx +++ b/frontend/src/app/pages/student/CourseRegistration.tsx @@ -216,6 +216,10 @@ const CourseRegistration: React.FC = () => { body = formDataObj; } + // Show "Checking registration status…" immediately while the API call is in progress + setIsRegistering(false); + setIsRegistered(true); + const response = await submitRegistration({ params: { path: { @@ -225,21 +229,26 @@ const CourseRegistration: React.FC = () => { body, }); - setIsRegistering(false); - setIsRegistered(true); setFormData(buildEmptyFormData(jsonSchema!)); const submissionStatus = getStatusValue(response); if (submissionStatus === 'APPROVED') { setRegistrationStatus('APPROVED'); + toast.success('You have been successfully registered! Redirecting to dashboard…'); + setTimeout(() => { + router.navigate({ to: '/student' }); + }, 2000); } else { setRegistrationStatus('PENDING'); } } catch (err: any) { + // Reset to form on error so the student can retry + setIsRegistered(false); + setRegistrationStatus('IDLE'); toast.error(err?.message || 'Something went wrong, please try again.'); - if(err?.message.includes("You are already enrolled")){ + if(err?.message?.includes("You are already enrolled")){ setTimeout(() => { router.navigate({ to: '/student' }); }, 1000); diff --git a/frontend/src/hooks/hooks.ts b/frontend/src/hooks/hooks.ts index 0aa11dd7d..ade9bd59c 100644 --- a/frontend/src/hooks/hooks.ts +++ b/frontend/src/hooks/hooks.ts @@ -4868,7 +4868,8 @@ export function useGetUnreadApprovedRegistrations(studentId: string): { } }, { enabled: !!studentId, - refetchOnWindowFocus: false + refetchOnWindowFocus: false, + refetchInterval: 30000 }); return { @@ -4942,7 +4943,12 @@ export function useMarkNotificationAsRead(): { reset: () => void, status: 'idle' | 'pending' | 'success' | 'error' } { - const result = api.useMutation("patch", "/course/registration/notifications/{registrationId}/read"); + const queryClient = useQueryClient(); + const result = api.useMutation("patch", "/course/registration/notifications/{registrationId}/read", { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["get", "/course/registration/notifications/unread"] }); + } + }); return { ...result, diff --git a/frontend/src/layouts/student-layout.tsx b/frontend/src/layouts/student-layout.tsx index 8f07a2ba2..8c19544ed 100644 --- a/frontend/src/layouts/student-layout.tsx +++ b/frontend/src/layouts/student-layout.tsx @@ -66,12 +66,12 @@ percentCompleted !== 100){ return pathname === path || pathname.startsWith(path + "/"); }; - // Sync local state with hook data + // Sync local state with hook data whenever the server response changes useEffect(() => { - if (approvedNotifications && approvedNotifications.length !== approvedNotificationsList.length) { + if (approvedNotifications) { setApprovedNotificationsList(approvedNotifications); } - }, [approvedNotifications, setApprovedNotificationsList,approvedNotificationsList]); + }, [approvedNotifications]); useEffect(() => { if (rejectedStudentRegistrations) { From 197d72a6973101f6b58eefce53cd00a6c22b5347 Mon Sep 17 00:00:00 2001 From: Meenakshi <45432051+MeenakshiArunsankar@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:08:11 +0530 Subject: [PATCH 03/24] Longest option always correct - fix for Smart Bloom mode (#921) --- frontend/src/app/pages/teacher/SmartBloomWorkflow.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/pages/teacher/SmartBloomWorkflow.tsx b/frontend/src/app/pages/teacher/SmartBloomWorkflow.tsx index 6c773dcd1..a1ee21340 100644 --- a/frontend/src/app/pages/teacher/SmartBloomWorkflow.tsx +++ b/frontend/src/app/pages/teacher/SmartBloomWorkflow.tsx @@ -1129,8 +1129,10 @@ const SmartBloomWorkflow = ({ onUploadComplete }: SmartBloomWorkflowProps = {}) "\n" + "OPTION LENGTH RULES (strict):\n" + "- Every answer option must be 8-20 words long.\n" + - "- The correct answer must NOT be longer than any distractor.\n" + - "- Write all four options at the same level of specificity and detail.\n" + + "- ALL options — correct and incorrect — must be within ±2 words of each other in length.\n" + + "- Write every distractor to be as specific, detailed, and plausible as the correct answer — not vague or shorter.\n" + + "- The correct answer must NOT have more words or characters than any distractor.\n" + + "- Before finalising, count the words in each option. If the correct answer is the longest, rewrite the distractors to match or exceed its length.\n" + "- A student comparing only option lengths must not be able to identify the correct answer.\n" + "- For Yes/No, True/False, or binary questions, provide exactly 2 options only.\n" + "\n" + From 0e6d9a6bffacdaaec86adcabffe3f47b8d003cc7 Mon Sep 17 00:00:00 2001 From: Meenakshi <45432051+MeenakshiArunsankar@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:15:11 +0530 Subject: [PATCH 04/24] Feature/crowdsourced question clean (#935) * feat: add crowdsourced question pipeline module * chore: normalize file permissions for crowdsourced module * Potential fix for pull request finding 'CodeQL / Inefficient regular expression' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../StudentQuestionMigration.ts | 273 ++++++++++++++++ .../StudentQuestionMigrationFixture.ts | 156 +++++++++ .../modules/studentQuestions/classes/index.ts | 2 + .../transformers/StudentSegmentQuestion.ts | 122 ++++++++ .../classes/transformers/index.ts | 1 + .../validators/StudentQuestionValidator.ts | 214 +++++++++++++ .../classes/validators/index.ts | 23 ++ .../src/modules/studentQuestions/container.ts | 19 ++ .../controllers/StudentQuestionController.ts | 129 ++++++++ .../studentQuestions/controllers/index.ts | 1 + backend/src/modules/studentQuestions/index.ts | 40 +++ .../studentQuestions/repositories/index.ts | 1 + .../repositories/providers/index.ts | 1 + .../mongodb/StudentQuestionRepository.ts | 162 ++++++++++ .../services/StudentQuestionService.ts | 5 +- .../studentQuestions/services/index.ts | 1 + .../tests/StudentQuestionMigration.test.ts | 62 ++++ .../tests/StudentQuestionService.test.ts | 98 ++++++ backend/src/modules/studentQuestions/types.ts | 6 + .../components/CrowdQuestionAttempt.tsx | 55 ++++ .../components/StudentQuestionComposer.tsx | 296 ++++++++++++++++++ frontend/src/lib/api/crowd-questions.ts | 52 +++ frontend/src/types/student-question.types.ts | 46 +++ 23 files changed, 1764 insertions(+), 1 deletion(-) create mode 100644 backend/src/modules/studentQuestions/StudentQuestionMigration.ts create mode 100644 backend/src/modules/studentQuestions/StudentQuestionMigrationFixture.ts create mode 100644 backend/src/modules/studentQuestions/classes/index.ts create mode 100644 backend/src/modules/studentQuestions/classes/transformers/StudentSegmentQuestion.ts create mode 100644 backend/src/modules/studentQuestions/classes/transformers/index.ts create mode 100644 backend/src/modules/studentQuestions/classes/validators/StudentQuestionValidator.ts create mode 100644 backend/src/modules/studentQuestions/classes/validators/index.ts create mode 100644 backend/src/modules/studentQuestions/container.ts create mode 100644 backend/src/modules/studentQuestions/controllers/StudentQuestionController.ts create mode 100644 backend/src/modules/studentQuestions/controllers/index.ts create mode 100644 backend/src/modules/studentQuestions/index.ts create mode 100644 backend/src/modules/studentQuestions/repositories/index.ts create mode 100644 backend/src/modules/studentQuestions/repositories/providers/index.ts create mode 100644 backend/src/modules/studentQuestions/repositories/providers/mongodb/StudentQuestionRepository.ts create mode 100644 backend/src/modules/studentQuestions/services/index.ts create mode 100644 backend/src/modules/studentQuestions/tests/StudentQuestionMigration.test.ts create mode 100644 backend/src/modules/studentQuestions/tests/StudentQuestionService.test.ts create mode 100644 backend/src/modules/studentQuestions/types.ts create mode 100644 frontend/src/app/pages/student/components/CrowdQuestionAttempt.tsx create mode 100644 frontend/src/components/StudentQuestionComposer.tsx create mode 100644 frontend/src/lib/api/crowd-questions.ts create mode 100644 frontend/src/types/student-question.types.ts diff --git a/backend/src/modules/studentQuestions/StudentQuestionMigration.ts b/backend/src/modules/studentQuestions/StudentQuestionMigration.ts new file mode 100644 index 000000000..2e04f42f3 --- /dev/null +++ b/backend/src/modules/studentQuestions/StudentQuestionMigration.ts @@ -0,0 +1,273 @@ +import {MongoClient, ObjectId} from 'mongodb'; +import {dbConfig} from '#root/config/db.js'; +import { + CrowdValidationState, + ICrowdValidationMetrics, + StudentQuestionStatus, +} from './classes/transformers/StudentSegmentQuestion.js'; + +type StudentQuestionMigrationDoc = { + _id: ObjectId; + status?: StudentQuestionStatus; + crowdValidationState?: CrowdValidationState; + crowdValidationMetrics?: ICrowdValidationMetrics; +}; + +type MigrationOptions = { + dryRun?: boolean; + batchSize?: number; + mongoUri?: string; + dbName?: string; + collectionName?: string; +}; + +type MigrationResult = { + scanned: number; + changed: number; + skipped: number; + failed: number; + dryRun: boolean; +}; + +type MigrationPatch = { + $set: Partial<{ + crowdValidationState: CrowdValidationState; + crowdValidationMetrics: ICrowdValidationMetrics; + updatedAt: Date; + }>; +}; + +const DEFAULT_METRICS: ICrowdValidationMetrics = { + totalAttempts: 0, + correctAttempts: 0, +}; + +export function mapStatusToCrowdValidationState( + status?: StudentQuestionStatus, +): CrowdValidationState { + if (status === 'VALIDATED') { + return 'KEPT'; + } + + if (status === 'REJECTED') { + return 'DISCARDED'; + } + + if (status === 'TO_BE_VALIDATED') { + return 'READY_FOR_CROWD'; + } + + return 'PENDING_CROWD_DATA'; +} + +function toNonNegativeNumber(value: unknown): number { + if (typeof value !== 'number' || Number.isNaN(value) || value < 0) { + return 0; + } + + return value; +} + +function hasValidMetrics(metrics?: ICrowdValidationMetrics): boolean { + if (!metrics) { + return false; + } + + if (typeof metrics.totalAttempts !== 'number') { + return false; + } + + if (typeof metrics.correctAttempts !== 'number') { + return false; + } + + if (metrics.totalAttempts < 0 || metrics.correctAttempts < 0) { + return false; + } + + if (metrics.correctAttempts > metrics.totalAttempts) { + return false; + } + + return true; +} + +export function buildCrowdValidationPatch( + document: StudentQuestionMigrationDoc, +): MigrationPatch | null { + const nextState = + document.crowdValidationState ?? mapStatusToCrowdValidationState(document.status); + + const existingMetrics = document.crowdValidationMetrics; + const nextMetrics = hasValidMetrics(existingMetrics) + ? { + totalAttempts: toNonNegativeNumber(existingMetrics?.totalAttempts), + correctAttempts: toNonNegativeNumber(existingMetrics?.correctAttempts), + ...(existingMetrics && typeof existingMetrics.correctRate === 'number' + ? {correctRate: existingMetrics.correctRate} + : {}), + } + : {...DEFAULT_METRICS}; + + const rate = + nextMetrics.totalAttempts > 0 + ? nextMetrics.correctAttempts / nextMetrics.totalAttempts + : undefined; + + const normalizedMetrics = { + ...nextMetrics, + ...(typeof rate === 'number' ? {correctRate: rate} : {}), + }; + + const isStateSame = document.crowdValidationState === nextState; + const isMetricsSame = + document.crowdValidationMetrics?.totalAttempts === normalizedMetrics.totalAttempts && + document.crowdValidationMetrics?.correctAttempts === normalizedMetrics.correctAttempts && + document.crowdValidationMetrics?.correctRate === normalizedMetrics.correctRate; + + if (isStateSame && isMetricsSame) { + return null; + } + + return { + $set: { + crowdValidationState: nextState, + crowdValidationMetrics: normalizedMetrics, + updatedAt: new Date(), + }, + }; +} + +export async function migrateStudentQuestionMetadata( + options: MigrationOptions = {}, +): Promise { + const dryRun = options.dryRun ?? true; + const batchSize = options.batchSize ?? 500; + const mongoUri = options.mongoUri ?? dbConfig.url; + const dbName = options.dbName ?? dbConfig.dbName; + const collectionName = options.collectionName ?? 'student_segment_questions'; + + if (!mongoUri) { + throw new Error('DB_URL is required to run student question migration.'); + } + + const client = new MongoClient(mongoUri, { + maxPoolSize: 10, + connectTimeoutMS: 20_000, + }); + + const result: MigrationResult = { + scanned: 0, + changed: 0, + skipped: 0, + failed: 0, + dryRun, + }; + + try { + await client.connect(); + + const collection = client + .db(dbName) + .collection(collectionName); + + const query = { + $or: [ + {crowdValidationState: {$exists: false}}, + {crowdValidationMetrics: {$exists: false}}, + {'crowdValidationMetrics.totalAttempts': {$exists: false}}, + {'crowdValidationMetrics.correctAttempts': {$exists: false}}, + ], + }; + + const cursor = collection.find(query, {batchSize}); + const updates: Array<{_id: ObjectId; patch: MigrationPatch}> = []; + + for await (const doc of cursor) { + result.scanned += 1; + + const patch = buildCrowdValidationPatch(doc); + if (!patch) { + result.skipped += 1; + continue; + } + + updates.push({_id: doc._id, patch}); + } + + if (dryRun) { + result.changed = updates.length; + return result; + } + + if (updates.length === 0) { + return result; + } + + for (let index = 0; index < updates.length; index += batchSize) { + const slice = updates.slice(index, index + batchSize); + const operations = slice.map(update => ({ + updateOne: { + filter: {_id: update._id}, + update: update.patch, + }, + })); + + const response = await collection.bulkWrite(operations, {ordered: false}); + result.changed += response.modifiedCount; + } + + return result; + } catch (error) { + result.failed += 1; + throw error; + } finally { + await client.close(); + } +} + +function parseArgs(argv: string[]) { + const execute = argv.includes('--execute'); + const dryRun = !execute; + + const batchArg = argv.find(arg => arg.startsWith('--batch-size=')); + const batchSize = batchArg ? Number(batchArg.split('=')[1]) : undefined; + + return { + dryRun, + batchSize: + typeof batchSize === 'number' && Number.isInteger(batchSize) && batchSize > 0 + ? batchSize + : undefined, + }; +} + +async function runFromCli() { + const options = parseArgs(process.argv.slice(2)); + + const summary = await migrateStudentQuestionMetadata(options); + + console.log('[StudentQuestionMigration] complete'); + console.log(`dryRun=${summary.dryRun}`); + console.log(`scanned=${summary.scanned}`); + console.log(`changed=${summary.changed}`); + console.log(`skipped=${summary.skipped}`); + console.log(`failed=${summary.failed}`); + + if (summary.dryRun) { + console.log('Run with --execute to apply updates.'); + } +} + +const isDirectRun = + process.argv[1] && + (process.argv[1].endsWith('/StudentQuestionMigration.js') || + process.argv[1].endsWith('/StudentQuestionMigration.ts')); + +if (isDirectRun) { + runFromCli().catch(error => { + console.error('[StudentQuestionMigration] failed'); + console.error(error); + process.exitCode = 1; + }); +} diff --git a/backend/src/modules/studentQuestions/StudentQuestionMigrationFixture.ts b/backend/src/modules/studentQuestions/StudentQuestionMigrationFixture.ts new file mode 100644 index 000000000..fcf1bedbb --- /dev/null +++ b/backend/src/modules/studentQuestions/StudentQuestionMigrationFixture.ts @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import {MongoClient, ObjectId} from 'mongodb'; +import {dbConfig} from '#root/config/db.js'; +import {migrateStudentQuestionMetadata} from './StudentQuestionMigration.js'; + +const COLLECTION_NAME = 'student_segment_questions'; + +function buildTempDbName(): string { + const base = (dbConfig.dbName || 'vibe').slice(0, 12); + const suffix = Date.now().toString(36); + return `${base}_sqmig_${suffix}`; +} + +async function seedFixtureDocs(mongoUri: string, dbName: string): Promise { + const client = new MongoClient(mongoUri); + + try { + await client.connect(); + const collection = client.db(dbName).collection(COLLECTION_NAME); + + await collection.insertMany([ + { + _id: new ObjectId(), + status: 'UNVERIFIED', + questionText: 'Fixture A', + }, + { + _id: new ObjectId(), + status: 'VALIDATED', + questionText: 'Fixture B', + }, + { + _id: new ObjectId(), + status: 'TO_BE_VALIDATED', + crowdValidationState: 'READY_FOR_CROWD', + questionText: 'Fixture C', + }, + { + _id: new ObjectId(), + status: 'REJECTED', + crowdValidationState: 'DISCARDED', + crowdValidationMetrics: { + totalAttempts: 4, + correctAttempts: 1, + correctRate: 0.25, + }, + questionText: 'Fixture D', + }, + ]); + } finally { + await client.close(); + } +} + +async function cleanupDb(mongoUri: string, dbName: string): Promise { + const client = new MongoClient(mongoUri); + + try { + await client.connect(); + try { + await client.db(dbName).dropDatabase(); + return; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown cleanup failure'; + + if (!message.includes('dropDatabase')) { + throw error; + } + + await client + .db(dbName) + .collection(COLLECTION_NAME) + .deleteMany({questionText: {$regex: /^Fixture /}}); + + console.log( + '[StudentQuestionMigrationFixture] dropDatabase not permitted, cleaned fixture documents instead', + ); + } + } finally { + await client.close(); + } +} + +async function runFixture() { + const keepDb = process.argv.includes('--keep-db'); + const mongoUri = dbConfig.url; + + if (!mongoUri) { + throw new Error('DB_URL is required for fixture test.'); + } + + const tempDbName = buildTempDbName(); + + console.log('[StudentQuestionMigrationFixture] setup'); + console.log(`dbName=${tempDbName}`); + + await seedFixtureDocs(mongoUri, tempDbName); + + const firstDryRun = await migrateStudentQuestionMetadata({ + dryRun: true, + dbName: tempDbName, + mongoUri, + collectionName: COLLECTION_NAME, + }); + + console.log('[StudentQuestionMigrationFixture] first dry run', firstDryRun); + + assert.equal(firstDryRun.scanned, 3, 'Expected first dry run scanned=3'); + assert.equal(firstDryRun.changed, 3, 'Expected first dry run changed=3'); + assert.equal(firstDryRun.skipped, 0, 'Expected first dry run skipped=0'); + assert.equal(firstDryRun.failed, 0, 'Expected first dry run failed=0'); + + const executeRun = await migrateStudentQuestionMetadata({ + dryRun: false, + dbName: tempDbName, + mongoUri, + collectionName: COLLECTION_NAME, + }); + + console.log('[StudentQuestionMigrationFixture] execute run', executeRun); + + assert.equal(executeRun.scanned, 3, 'Expected execute run scanned=3'); + assert.equal(executeRun.changed, 3, 'Expected execute run changed=3'); + assert.equal(executeRun.failed, 0, 'Expected execute run failed=0'); + + const secondDryRun = await migrateStudentQuestionMetadata({ + dryRun: true, + dbName: tempDbName, + mongoUri, + collectionName: COLLECTION_NAME, + }); + + console.log('[StudentQuestionMigrationFixture] second dry run', secondDryRun); + + assert.equal(secondDryRun.scanned, 0, 'Expected second dry run scanned=0'); + assert.equal(secondDryRun.changed, 0, 'Expected second dry run changed=0'); + assert.equal(secondDryRun.skipped, 0, 'Expected second dry run skipped=0'); + assert.equal(secondDryRun.failed, 0, 'Expected second dry run failed=0'); + + console.log('[StudentQuestionMigrationFixture] success'); + + if (keepDb) { + console.log('[StudentQuestionMigrationFixture] keeping temp database for inspection'); + return; + } + + await cleanupDb(mongoUri, tempDbName); + console.log('[StudentQuestionMigrationFixture] cleaned up temp database'); +} + +runFixture().catch(error => { + console.error('[StudentQuestionMigrationFixture] failed'); + console.error(error); + process.exitCode = 1; +}); diff --git a/backend/src/modules/studentQuestions/classes/index.ts b/backend/src/modules/studentQuestions/classes/index.ts new file mode 100644 index 000000000..809bc3a53 --- /dev/null +++ b/backend/src/modules/studentQuestions/classes/index.ts @@ -0,0 +1,2 @@ +export * from './transformers/index.js'; +export * from './validators/index.js'; diff --git a/backend/src/modules/studentQuestions/classes/transformers/StudentSegmentQuestion.ts b/backend/src/modules/studentQuestions/classes/transformers/StudentSegmentQuestion.ts new file mode 100644 index 000000000..ba0d0266e --- /dev/null +++ b/backend/src/modules/studentQuestions/classes/transformers/StudentSegmentQuestion.ts @@ -0,0 +1,122 @@ +import {ObjectId} from 'mongodb'; + +export type StudentQuestionStatus = + | 'UNVERIFIED' + | 'TO_BE_VALIDATED' + | 'VALIDATED' + | 'REJECTED'; + +export type StudentQuestionSource = + | 'STUDENT_GENERATED' + | 'INSTRUCTOR_GENERATED' + | 'AI_GENERATED'; + +export type StudentQuestionType = 'SELECT_ONE_IN_LOT'; + +export type CrowdValidationState = + | 'PENDING_CROWD_DATA' + | 'READY_FOR_CROWD' + | 'KEPT' + | 'DISCARDED' + | 'FLAGGED_FOR_REVISION'; + +export interface ICrowdValidationMetrics { + totalAttempts: number; + correctAttempts: number; + correctRate?: number; // Computed: correctAttempts / totalAttempts +} + +export interface IStudentQuestionOption { + text?: string; + imageUrl?: string; +} + +export interface IStudentSegmentQuestion { + _id?: ObjectId; + courseId: ObjectId; + courseVersionId: ObjectId; + segmentId: ObjectId; + questionType: StudentQuestionType; + questionText: string; + questionImageUrl?: string; + options: IStudentQuestionOption[]; + correctOptionIndex: number; + normalizedQuestionText: string; + status: StudentQuestionStatus; + source: StudentQuestionSource; + createdBy: ObjectId; + reviewedBy?: ObjectId; + reviewedAt?: Date; + rejectionReason?: string; + createdAt: Date; + updatedAt: Date; + isDeleted?: boolean; + deletedAt?: Date; + // Crowd validation fields (V2.0) + crowdValidationState?: CrowdValidationState; + crowdValidationMetrics?: ICrowdValidationMetrics; + lastValidationCheck?: Date; +} + +export class StudentSegmentQuestion implements IStudentSegmentQuestion { + _id?: ObjectId; + courseId: ObjectId; + courseVersionId: ObjectId; + segmentId: ObjectId; + questionType: StudentQuestionType; + questionText: string; + questionImageUrl?: string; + options: IStudentQuestionOption[]; + correctOptionIndex: number; + normalizedQuestionText: string; + status: StudentQuestionStatus; + source: StudentQuestionSource; + createdBy: ObjectId; + reviewedBy?: ObjectId; + reviewedAt?: Date; + rejectionReason?: string; + createdAt: Date; + updatedAt: Date; + isDeleted?: boolean; + deletedAt?: Date; + // Crowd validation fields (V2.0) + crowdValidationState?: CrowdValidationState; + crowdValidationMetrics?: ICrowdValidationMetrics; + lastValidationCheck?: Date; + + constructor(input: { + courseId: string; + courseVersionId: string; + segmentId: string; + questionType: StudentQuestionType; + questionText: string; + questionImageUrl?: string; + options: IStudentQuestionOption[]; + correctOptionIndex: number; + normalizedQuestionText: string; + createdBy: string; + }) { + this.courseId = new ObjectId(input.courseId); + this.courseVersionId = new ObjectId(input.courseVersionId); + this.segmentId = new ObjectId(input.segmentId); + this.questionType = input.questionType; + this.questionText = input.questionText; + this.questionImageUrl = input.questionImageUrl; + this.options = input.options; + this.correctOptionIndex = input.correctOptionIndex; + this.normalizedQuestionText = input.normalizedQuestionText; + this.status = 'UNVERIFIED'; + this.source = 'STUDENT_GENERATED'; + this.createdBy = new ObjectId(input.createdBy); + this.reviewedBy = undefined; + this.reviewedAt = undefined; + this.rejectionReason = undefined; + this.createdAt = new Date(); + this.updatedAt = new Date(); + this.isDeleted = false; + // Initialize crowd validation fields + this.crowdValidationState = 'PENDING_CROWD_DATA'; + this.crowdValidationMetrics = { totalAttempts: 0, correctAttempts: 0 }; + this.lastValidationCheck = undefined; + } +} diff --git a/backend/src/modules/studentQuestions/classes/transformers/index.ts b/backend/src/modules/studentQuestions/classes/transformers/index.ts new file mode 100644 index 000000000..5f50cc03b --- /dev/null +++ b/backend/src/modules/studentQuestions/classes/transformers/index.ts @@ -0,0 +1 @@ +export * from './StudentSegmentQuestion.js'; diff --git a/backend/src/modules/studentQuestions/classes/validators/StudentQuestionValidator.ts b/backend/src/modules/studentQuestions/classes/validators/StudentQuestionValidator.ts new file mode 100644 index 000000000..bb9b39549 --- /dev/null +++ b/backend/src/modules/studentQuestions/classes/validators/StudentQuestionValidator.ts @@ -0,0 +1,214 @@ +import {JSONSchema} from 'class-validator-jsonschema'; +import {Type} from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsIn, + IsInt, + IsMongoId, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Length, + Max, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; + +export class StudentQuestionPathParams { + @IsNotEmpty() + @IsMongoId() + @JSONSchema({example: '65b7c8c8c8c8c8c8c8c8c8c8'}) + courseId: string; + + @IsNotEmpty() + @IsMongoId() + @JSONSchema({example: '65b7c8c8c8c8c8c8c8c8c8c9'}) + courseVersionId: string; + + @IsNotEmpty() + @IsMongoId() + @JSONSchema({example: '65b7c8c8c8c8c8c8c8c8c8ca'}) + segmentId: string; +} + +export class StudentQuestionStatusPathParams extends StudentQuestionPathParams { + @IsNotEmpty() + @IsMongoId() + @JSONSchema({example: '65b7c8c8c8c8c8c8c8c8c8cb'}) + questionId: string; +} + +export class CreateStudentQuestionBody { + @IsNotEmpty() + @IsString() + @Length(10, 300) + @JSONSchema({ + description: 'Student submitted question text', + minLength: 10, + maxLength: 300, + example: 'Can someone explain why binary search needs sorted input?', + }) + questionText: string; + + @IsNotEmpty() + @IsString() + @IsIn(['SELECT_ONE_IN_LOT']) + @JSONSchema({example: 'SELECT_ONE_IN_LOT'}) + questionType: 'SELECT_ONE_IN_LOT'; + + @IsOptional() + @IsString() + @MaxLength(400000) + @JSONSchema({ + description: 'Optional question image as URL or data URL', + example: 'https://example.com/question-image.png', + }) + questionImageUrl?: string; + + @IsArray() + @ArrayMinSize(2) + @ArrayMaxSize(8) + @ValidateNested({each: true}) + @Type(() => StudentQuestionOptionBody) + options: StudentQuestionOptionBody[]; + + @IsNotEmpty() + @IsNumber() + @IsInt() + @Min(0) + correctOptionIndex: number; +} + +export class StudentQuestionOptionBody { + @IsNotEmpty() + @IsString() + @Length(1, 150) + @JSONSchema({example: 'Sorted arrays'}) + text: string; + + @IsOptional() + @IsString() + @MaxLength(400000) + @JSONSchema({ + description: 'Optional option image as URL or data URL', + example: 'https://example.com/option-a.png', + }) + imageUrl?: string; +} + +export class StudentQuestionCreateResponse { + @IsString() + @IsNotEmpty() + @JSONSchema({example: '65b7c8c8c8c8c8c8c8c8c8cb'}) + questionId: string; +} + +export class UpdateStudentQuestionStatusBody { + @IsNotEmpty() + @IsString() + @IsIn(['UNVERIFIED', 'TO_BE_VALIDATED', 'VALIDATED', 'REJECTED']) + @JSONSchema({example: 'VALIDATED'}) + status: 'UNVERIFIED' | 'TO_BE_VALIDATED' | 'VALIDATED' | 'REJECTED'; + + @IsOptional() + @IsString() + @Length(3, 500) + @JSONSchema({example: 'Question is conceptually incorrect for this segment.'}) + reason?: string; +} + +export class StudentQuestionListQuery { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} + +export class StudentQuestionListItem { + @IsString() + @IsNotEmpty() + _id: string; + + @IsString() + @IsNotEmpty() + questionType: string; + + @IsString() + @IsNotEmpty() + questionText: string; + + @IsOptional() + @IsString() + questionImageUrl?: string; + + @IsArray() + @ValidateNested({each: true}) + @Type(() => StudentQuestionOptionItem) + options: StudentQuestionOptionItem[]; + + @IsInt() + correctOptionIndex: number; + + @IsString() + @IsNotEmpty() + status: string; + + @IsString() + @IsNotEmpty() + source: string; + + @IsString() + @IsNotEmpty() + createdBy: string; + + @IsString() + @IsNotEmpty() + createdAt: string; + + @IsOptional() + @IsString() + rejectionReason?: string; + + @IsOptional() + @IsString() + reviewedBy?: string; + + @IsOptional() + @IsString() + reviewedAt?: string; + + // Crowd validation fields (V2.0 - internal staff only) + @IsOptional() + @IsString() + crowdValidationState?: string; + + @IsOptional() + crowdValidationMetrics?: { + totalAttempts: number; + correctAttempts: number; + correctRate?: number; + }; + + @IsOptional() + lastValidationCheck?: string; +} + +export class StudentQuestionListResponse { + items: StudentQuestionListItem[]; +} + +export class StudentQuestionOptionItem { + @IsOptional() + @IsString() + text?: string; + + @IsOptional() + @IsString() + imageUrl?: string; +} diff --git a/backend/src/modules/studentQuestions/classes/validators/index.ts b/backend/src/modules/studentQuestions/classes/validators/index.ts new file mode 100644 index 000000000..c28990208 --- /dev/null +++ b/backend/src/modules/studentQuestions/classes/validators/index.ts @@ -0,0 +1,23 @@ +import { + CreateStudentQuestionBody, + StudentQuestionOptionBody, + StudentQuestionCreateResponse, + StudentQuestionOptionItem, + StudentQuestionListItem, + StudentQuestionListQuery, + StudentQuestionListResponse, + StudentQuestionPathParams, +} from './StudentQuestionValidator.js'; + +export * from './StudentQuestionValidator.js'; + +export const STUDENT_QUESTION_VALIDATORS: Function[] = [ + StudentQuestionPathParams, + CreateStudentQuestionBody, + StudentQuestionOptionBody, + StudentQuestionCreateResponse, + StudentQuestionListQuery, + StudentQuestionOptionItem, + StudentQuestionListResponse, + StudentQuestionListItem, +]; diff --git a/backend/src/modules/studentQuestions/container.ts b/backend/src/modules/studentQuestions/container.ts new file mode 100644 index 000000000..de1ff383c --- /dev/null +++ b/backend/src/modules/studentQuestions/container.ts @@ -0,0 +1,19 @@ +import {ContainerModule} from 'inversify'; +import {STUDENT_QUESTION_TYPES} from './types.js'; +import {StudentQuestionRepository} from './repositories/providers/mongodb/StudentQuestionRepository.js'; +import {StudentQuestionService} from './services/StudentQuestionService.js'; +import {StudentQuestionController} from './controllers/StudentQuestionController.js'; + +export const studentQuestionsContainerModule = new ContainerModule(options => { + options + .bind(STUDENT_QUESTION_TYPES.StudentQuestionRepo) + .to(StudentQuestionRepository) + .inSingletonScope(); + + options + .bind(STUDENT_QUESTION_TYPES.StudentQuestionService) + .to(StudentQuestionService) + .inSingletonScope(); + + options.bind(StudentQuestionController).toSelf().inSingletonScope(); +}); diff --git a/backend/src/modules/studentQuestions/controllers/StudentQuestionController.ts b/backend/src/modules/studentQuestions/controllers/StudentQuestionController.ts new file mode 100644 index 000000000..d7711b0d9 --- /dev/null +++ b/backend/src/modules/studentQuestions/controllers/StudentQuestionController.ts @@ -0,0 +1,129 @@ +import {inject, injectable} from 'inversify'; +import { + Authorized, + Body, + CurrentUser, + ForbiddenError, + Get, + HttpCode, + JsonController, + Patch, + Params, + Post, + QueryParams, +} from 'routing-controllers'; +import {OpenAPI, ResponseSchema} from 'routing-controllers-openapi'; +import {IUser} from '#root/shared/interfaces/models.js'; +import {STUDENT_QUESTION_TYPES} from '../types.js'; +import {StudentQuestionService} from '../services/StudentQuestionService.js'; +import { + CreateStudentQuestionBody, + StudentQuestionCreateResponse, + StudentQuestionListQuery, + StudentQuestionListResponse, + StudentQuestionPathParams, + StudentQuestionStatusPathParams, + UpdateStudentQuestionStatusBody, +} from '../classes/validators/StudentQuestionValidator.js'; + +@OpenAPI({ + tags: ['Student Questions'], +}) +@JsonController('/student-questions') +@injectable() +export class StudentQuestionController { + constructor( + @inject(STUDENT_QUESTION_TYPES.StudentQuestionService) + private readonly service: StudentQuestionService, + ) {} + + @Authorized() + @Post('/courses/:courseId/versions/:courseVersionId/segments/:segmentId') + @HttpCode(201) + @ResponseSchema(StudentQuestionCreateResponse) + async create( + @Params() params: StudentQuestionPathParams, + @Body() body: CreateStudentQuestionBody, + @CurrentUser() user: IUser, + ): Promise { + const createdBy = user._id?.toString(); + if (!createdBy) { + throw new ForbiddenError('Unable to resolve authenticated user.'); + } + + const questionId = await this.service.createQuestion({ + courseId: params.courseId, + courseVersionId: params.courseVersionId, + segmentId: params.segmentId, + questionType: body.questionType, + questionText: body.questionText, + questionImageUrl: body.questionImageUrl, + options: body.options, + correctOptionIndex: body.correctOptionIndex, + createdBy, + }); + + return {questionId}; + } + + @Authorized() + @Get('/courses/:courseId/versions/:courseVersionId/segments/:segmentId') + @HttpCode(200) + @ResponseSchema(StudentQuestionListResponse) + async listBySegment( + @Params() params: StudentQuestionPathParams, + @QueryParams() query: StudentQuestionListQuery, + @CurrentUser() _user: IUser, + ): Promise { + const questions = await this.service.listSegmentQuestions({ + courseId: params.courseId, + courseVersionId: params.courseVersionId, + segmentId: params.segmentId, + limit: query.limit ?? 20, + }); + + return { + items: questions.map(question => ({ + _id: question._id?.toString() || '', + questionType: question.questionType, + questionText: question.questionText, + questionImageUrl: question.questionImageUrl, + options: question.options, + correctOptionIndex: question.correctOptionIndex, + status: question.status, + source: question.source, + createdBy: question.createdBy.toString(), + createdAt: question.createdAt.toISOString(), + rejectionReason: question.rejectionReason, + reviewedBy: question.reviewedBy?.toString(), + reviewedAt: question.reviewedAt?.toISOString(), + })), + }; + } + + @Authorized() + @Patch('/courses/:courseId/versions/:courseVersionId/segments/:segmentId/questions/:questionId/status') + @HttpCode(200) + async updateStatus( + @Params() params: StudentQuestionStatusPathParams, + @Body() body: UpdateStudentQuestionStatusBody, + @CurrentUser() user: IUser, + ): Promise<{success: true}> { + const reviewedBy = user._id?.toString(); + if (!reviewedBy) { + throw new ForbiddenError('Unable to resolve authenticated user.'); + } + + await this.service.updateQuestionStatus({ + courseId: params.courseId, + courseVersionId: params.courseVersionId, + segmentId: params.segmentId, + questionId: params.questionId, + status: body.status, + reviewedBy, + reason: body.reason, + }); + + return {success: true}; + } +} diff --git a/backend/src/modules/studentQuestions/controllers/index.ts b/backend/src/modules/studentQuestions/controllers/index.ts new file mode 100644 index 000000000..0f1fa944d --- /dev/null +++ b/backend/src/modules/studentQuestions/controllers/index.ts @@ -0,0 +1 @@ +export * from './StudentQuestionController.js'; diff --git a/backend/src/modules/studentQuestions/index.ts b/backend/src/modules/studentQuestions/index.ts new file mode 100644 index 000000000..f6c8b84cf --- /dev/null +++ b/backend/src/modules/studentQuestions/index.ts @@ -0,0 +1,40 @@ +import {Container, ContainerModule} from 'inversify'; +import {RoutingControllersOptions, useContainer} from 'routing-controllers'; +import {studentQuestionsContainerModule} from './container.js'; +import {sharedContainerModule} from '#root/container.js'; +import {authContainerModule} from '../auth/container.js'; +import {usersContainerModule} from '../users/container.js'; +import {StudentQuestionController} from './controllers/StudentQuestionController.js'; +import {InversifyAdapter} from '#root/inversify-adapter.js'; +import {authorizationChecker, HttpErrorHandler} from '#root/shared/index.js'; +import {STUDENT_QUESTION_VALIDATORS} from './classes/validators/index.js'; + +export const studentQuestionsContainerModules: ContainerModule[] = [ + studentQuestionsContainerModule, + sharedContainerModule, + authContainerModule, + usersContainerModule, +]; + +export const studentQuestionsModuleControllers: Function[] = [ + StudentQuestionController, +]; + +export async function setupStudentQuestionsContainer(): Promise { + const container = new Container(); + await container.load(...studentQuestionsContainerModules); + const inversifyAdapter = new InversifyAdapter(container); + useContainer(inversifyAdapter); +} + +export const studentQuestionsModuleOptions: RoutingControllersOptions = { + controllers: studentQuestionsModuleControllers, + middlewares: [HttpErrorHandler], + defaultErrorHandler: false, + authorizationChecker, + validation: true, +}; + +export const studentQuestionsModuleValidators: Function[] = [ + ...STUDENT_QUESTION_VALIDATORS, +]; diff --git a/backend/src/modules/studentQuestions/repositories/index.ts b/backend/src/modules/studentQuestions/repositories/index.ts new file mode 100644 index 000000000..5587fdf2f --- /dev/null +++ b/backend/src/modules/studentQuestions/repositories/index.ts @@ -0,0 +1 @@ +export * from './providers/index.js'; diff --git a/backend/src/modules/studentQuestions/repositories/providers/index.ts b/backend/src/modules/studentQuestions/repositories/providers/index.ts new file mode 100644 index 000000000..450707b84 --- /dev/null +++ b/backend/src/modules/studentQuestions/repositories/providers/index.ts @@ -0,0 +1 @@ +export * from './mongodb/StudentQuestionRepository.js'; diff --git a/backend/src/modules/studentQuestions/repositories/providers/mongodb/StudentQuestionRepository.ts b/backend/src/modules/studentQuestions/repositories/providers/mongodb/StudentQuestionRepository.ts new file mode 100644 index 000000000..4ebb1b711 --- /dev/null +++ b/backend/src/modules/studentQuestions/repositories/providers/mongodb/StudentQuestionRepository.ts @@ -0,0 +1,162 @@ +import {inject, injectable} from 'inversify'; +import {Collection, ObjectId} from 'mongodb'; +import {MongoDatabase} from '#shared/index.js'; +import {GLOBAL_TYPES} from '#root/types.js'; +import { + IStudentSegmentQuestion, + StudentQuestionStatus, + CrowdValidationState, + ICrowdValidationMetrics, +} from '../../../classes/transformers/StudentSegmentQuestion.js'; + +@injectable() +export class StudentQuestionRepository { + private collection: Collection; + private initialized = false; + + constructor( + @inject(GLOBAL_TYPES.Database) + private readonly db: MongoDatabase, + ) {} + + private async init() { + if (!this.initialized) { + this.collection = await this.db.getCollection( + 'student_segment_questions', + ); + await this.collection.createIndex({ + courseVersionId: 1, + segmentId: 1, + normalizedQuestionText: 1, + }); + await this.collection.createIndex({courseVersionId: 1, segmentId: 1, createdAt: -1}); + // Index for batch validation queries (V2.0) + await this.collection.createIndex({segmentId: 1, crowdValidationState: 1}); + this.initialized = true; + } + } + + async create(question: IStudentSegmentQuestion): Promise { + await this.init(); + const result = await this.collection.insertOne(question); + return result.insertedId.toString(); + } + + async findDuplicate(input: { + courseVersionId: string; + segmentId: string; + normalizedQuestionText: string; + }): Promise { + await this.init(); + return await this.collection.findOne({ + courseVersionId: new ObjectId(input.courseVersionId), + segmentId: new ObjectId(input.segmentId), + normalizedQuestionText: input.normalizedQuestionText, + $or: [{isDeleted: {$exists: false}}, {isDeleted: false}], + }); + } + + async listBySegment(input: { + courseId: string; + courseVersionId: string; + segmentId: string; + limit: number; + }): Promise { + await this.init(); + return await this.collection + .find( + { + courseId: new ObjectId(input.courseId), + courseVersionId: new ObjectId(input.courseVersionId), + segmentId: new ObjectId(input.segmentId), + $or: [{isDeleted: {$exists: false}}, {isDeleted: false}], + }, + {sort: {createdAt: -1}, limit: input.limit}, + ) + .toArray(); + } + + async updateStatus(input: { + courseId: string; + courseVersionId: string; + segmentId: string; + questionId: string; + status: StudentQuestionStatus; + reviewedBy: string; + rejectionReason?: string; + }): Promise { + await this.init(); + + const updateDoc: any = { + $set: { + status: input.status, + reviewedBy: new ObjectId(input.reviewedBy), + reviewedAt: new Date(), + updatedAt: new Date(), + }, + }; + + if (input.status === 'REJECTED') { + updateDoc.$set.rejectionReason = input.rejectionReason?.trim() || ''; + } else { + updateDoc.$unset = {rejectionReason: ''}; + } + + const result = await this.collection.updateOne( + { + _id: new ObjectId(input.questionId), + courseId: new ObjectId(input.courseId), + courseVersionId: new ObjectId(input.courseVersionId), + segmentId: new ObjectId(input.segmentId), + $or: [{isDeleted: {$exists: false}}, {isDeleted: false}], + }, + updateDoc, + ); + + return result.matchedCount > 0; + } + + async updateCrowdValidationMetrics(input: { + questionId: string; + metrics: ICrowdValidationMetrics; + validationState: CrowdValidationState; + }): Promise { + await this.init(); + + const result = await this.collection.updateOne( + { + _id: new ObjectId(input.questionId), + $or: [{isDeleted: {$exists: false}}, {isDeleted: false}], + }, + { + $set: { + crowdValidationMetrics: input.metrics, + crowdValidationState: input.validationState, + lastValidationCheck: new Date(), + updatedAt: new Date(), + }, + }, + ); + + return result.matchedCount > 0; + } + + async findByValidationState(input: { + segmentId: string; + validationState: CrowdValidationState; + limit?: number; + }): Promise { + await this.init(); + + return await this.collection + .find( + { + segmentId: new ObjectId(input.segmentId), + crowdValidationState: input.validationState, + $or: [{isDeleted: {$exists: false}}, {isDeleted: false}], + }, + {sort: {lastValidationCheck: -1}, limit: input.limit || 100}, + ) + .toArray(); + } +} diff --git a/backend/src/modules/studentQuestions/services/StudentQuestionService.ts b/backend/src/modules/studentQuestions/services/StudentQuestionService.ts index c75259edb..b4a5d5397 100644 --- a/backend/src/modules/studentQuestions/services/StudentQuestionService.ts +++ b/backend/src/modules/studentQuestions/services/StudentQuestionService.ts @@ -60,7 +60,10 @@ export class StudentQuestionService { throw new BadRequestError('Question must be between 10 and 300 characters.'); } - if (/^https?:\/\/\S+(\s+https?:\/\/\S+)*\s*$/.test(normalized)) { + const tokens = normalized.split(/\s+/).filter(Boolean); + const isOnlyUrls = + tokens.length > 0 && tokens.every(token => /^https?:\/\/\S+$/i.test(token)); + if (isOnlyUrls) { throw new BadRequestError('Question cannot contain only URLs.'); } diff --git a/backend/src/modules/studentQuestions/services/index.ts b/backend/src/modules/studentQuestions/services/index.ts new file mode 100644 index 000000000..e43240cb3 --- /dev/null +++ b/backend/src/modules/studentQuestions/services/index.ts @@ -0,0 +1 @@ +export * from './StudentQuestionService.js'; diff --git a/backend/src/modules/studentQuestions/tests/StudentQuestionMigration.test.ts b/backend/src/modules/studentQuestions/tests/StudentQuestionMigration.test.ts new file mode 100644 index 000000000..c34181ab5 --- /dev/null +++ b/backend/src/modules/studentQuestions/tests/StudentQuestionMigration.test.ts @@ -0,0 +1,62 @@ +import {describe, expect, it} from 'vitest'; +import {ObjectId} from 'mongodb'; +import { + buildCrowdValidationPatch, + mapStatusToCrowdValidationState, +} from '../StudentQuestionMigration.js'; + +describe('StudentQuestionMigration', () => { + it('maps legacy statuses to expected crowd states', () => { + expect(mapStatusToCrowdValidationState('UNVERIFIED')).toBe('PENDING_CROWD_DATA'); + expect(mapStatusToCrowdValidationState('TO_BE_VALIDATED')).toBe('READY_FOR_CROWD'); + expect(mapStatusToCrowdValidationState('VALIDATED')).toBe('KEPT'); + expect(mapStatusToCrowdValidationState('REJECTED')).toBe('DISCARDED'); + }); + + it('builds patch for legacy V1 document with no crowd fields', () => { + const patch = buildCrowdValidationPatch({ + _id: new ObjectId(), + status: 'TO_BE_VALIDATED', + }); + + expect(patch).not.toBeNull(); + expect(patch?.$set.crowdValidationState).toBe('READY_FOR_CROWD'); + expect(patch?.$set.crowdValidationMetrics).toEqual({ + totalAttempts: 0, + correctAttempts: 0, + }); + }); + + it('returns null when document already has normalized V2 fields', () => { + const patch = buildCrowdValidationPatch({ + _id: new ObjectId(), + status: 'VALIDATED', + crowdValidationState: 'KEPT', + crowdValidationMetrics: { + totalAttempts: 10, + correctAttempts: 6, + correctRate: 0.6, + }, + }); + + expect(patch).toBeNull(); + }); + + it('normalizes invalid metrics and recomputes rate', () => { + const patch = buildCrowdValidationPatch({ + _id: new ObjectId(), + status: 'UNVERIFIED', + crowdValidationState: 'PENDING_CROWD_DATA', + crowdValidationMetrics: { + totalAttempts: 5, + correctAttempts: 7, + }, + }); + + expect(patch).not.toBeNull(); + expect(patch?.$set.crowdValidationMetrics).toEqual({ + totalAttempts: 0, + correctAttempts: 0, + }); + }); +}); diff --git a/backend/src/modules/studentQuestions/tests/StudentQuestionService.test.ts b/backend/src/modules/studentQuestions/tests/StudentQuestionService.test.ts new file mode 100644 index 000000000..bad73b9a9 --- /dev/null +++ b/backend/src/modules/studentQuestions/tests/StudentQuestionService.test.ts @@ -0,0 +1,98 @@ +import {describe, expect, it, vi, beforeEach} from 'vitest'; +import {StudentQuestionService} from '../services/StudentQuestionService.js'; + +describe('StudentQuestionService', () => { + const baseInput = { + courseId: '65b7c8c8c8c8c8c8c8c8c8c8', + courseVersionId: '65b7c8c8c8c8c8c8c8c8c8c9', + segmentId: '65b7c8c8c8c8c8c8c8c8c8ca', + createdBy: '65b7c8c8c8c8c8c8c8c8c8cb', + questionType: 'SELECT_ONE_IN_LOT' as const, + questionText: 'Why do we need sorted input for binary search?', + options: [ + {text: 'It helps us repeatedly halve the search space.'}, + {text: 'It makes the array easier to print.'}, + {text: 'It is required only for linked lists.'}, + ], + correctOptionIndex: 0, + }; + + const repository = { + findDuplicate: vi.fn(), + create: vi.fn(), + }; + + const settingRepo = { + readCourseSettings: vi.fn(), + }; + + let service: StudentQuestionService; + + beforeEach(() => { + vi.resetAllMocks(); + service = new StudentQuestionService(repository as any, settingRepo as any); + }); + + it('rejects submissions when feature flag is disabled', async () => { + settingRepo.readCourseSettings.mockResolvedValue({ + settings: {crowdsourcedQuestionSubmissionEnabled: false}, + }); + + await expect(service.createQuestion(baseInput)).rejects.toThrow( + 'Question submission is not enabled for this course version.', + ); + }); + + it('rejects URL-only question text', async () => { + settingRepo.readCourseSettings.mockResolvedValue({ + settings: {crowdsourcedQuestionSubmissionEnabled: true}, + }); + + await expect( + service.createQuestion({...baseInput, questionText: 'https://example.com'}), + ).rejects.toThrow('Question cannot contain only URLs.'); + }); + + it('rejects MCQ submissions with fewer than two options', async () => { + settingRepo.readCourseSettings.mockResolvedValue({ + settings: {crowdsourcedQuestionSubmissionEnabled: true}, + }); + + await expect( + service.createQuestion({...baseInput, options: [{text: 'Only one option'}]}), + ).rejects.toThrow('MCQ submissions must include between 2 and 8 options.'); + }); + + it('rejects duplicate questions in the same segment', async () => { + settingRepo.readCourseSettings.mockResolvedValue({ + settings: {crowdsourcedQuestionSubmissionEnabled: true}, + }); + repository.findDuplicate.mockResolvedValue({ + _id: 'existing-question-id', + }); + + await expect(service.createQuestion(baseInput)).rejects.toThrow( + 'A similar question already exists for this segment.', + ); + }); + + it('stores V1 metadata when valid submission is created', async () => { + settingRepo.readCourseSettings.mockResolvedValue({ + settings: {crowdsourcedQuestionSubmissionEnabled: true}, + }); + repository.findDuplicate.mockResolvedValue(null); + repository.create.mockResolvedValue('new-question-id'); + + const id = await service.createQuestion(baseInput); + + expect(id).toBe('new-question-id'); + expect(repository.create).toHaveBeenCalledTimes(1); + const payload = repository.create.mock.calls[0][0]; + expect(payload.status).toBe('UNVERIFIED'); + expect(payload.source).toBe('STUDENT_GENERATED'); + expect(payload.questionType).toBe('SELECT_ONE_IN_LOT'); + expect(payload.questionText).toBe(baseInput.questionText); + expect(payload.options).toEqual(baseInput.options); + expect(payload.correctOptionIndex).toBe(0); + }); +}); diff --git a/backend/src/modules/studentQuestions/types.ts b/backend/src/modules/studentQuestions/types.ts new file mode 100644 index 000000000..a012fabfb --- /dev/null +++ b/backend/src/modules/studentQuestions/types.ts @@ -0,0 +1,6 @@ +const TYPES = { + StudentQuestionRepo: Symbol.for('StudentQuestionRepo'), + StudentQuestionService: Symbol.for('StudentQuestionService'), +}; + +export {TYPES as STUDENT_QUESTION_TYPES}; diff --git a/frontend/src/app/pages/student/components/CrowdQuestionAttempt.tsx b/frontend/src/app/pages/student/components/CrowdQuestionAttempt.tsx new file mode 100644 index 000000000..f55a05629 --- /dev/null +++ b/frontend/src/app/pages/student/components/CrowdQuestionAttempt.tsx @@ -0,0 +1,55 @@ +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +interface CrowdQuestionAttemptProps { + question: any; // Replace with your question type + onSubmit: (answer: any) => Promise; +} + +const CrowdQuestionAttempt: React.FC = ({ question, onSubmit }) => { + const [selectedOption, setSelectedOption] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + + if (!question) return null; + + const handleSubmit = async () => { + setSubmitting(true); + await onSubmit(selectedOption); + setSubmitting(false); + setSubmitted(true); + }; + + return ( + + +

Crowdsourced Question (Ungraded)

+
{question.text}
+
+ {question.options?.map((opt: string, idx: number) => ( + + ))} +
+ +
+
+ ); +}; + +export default CrowdQuestionAttempt; diff --git a/frontend/src/components/StudentQuestionComposer.tsx b/frontend/src/components/StudentQuestionComposer.tsx new file mode 100644 index 000000000..d3dfee3f2 --- /dev/null +++ b/frontend/src/components/StudentQuestionComposer.tsx @@ -0,0 +1,296 @@ +import {useEffect, useState} from 'react'; +import {Paperclip, Plus, Trash2, X} from 'lucide-react'; +import {toast} from 'sonner'; +import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {Label} from '@/components/ui/label'; +import {Textarea} from '@/components/ui/textarea'; +import type { + StudentQuestionOptionInput, + StudentQuestionSubmissionPayload, +} from '@/types/student-question.types'; + +const MAX_OPTIONS = 8; +const MIN_OPTIONS = 2; +const MAX_IMAGE_FILE_SIZE = 512 * 1024; +const OPTION_LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; + +function createEmptyOption(): StudentQuestionOptionInput { + return {text: '', imageUrl: ''}; +} + +function createInitialPayload(): StudentQuestionSubmissionPayload { + return { + questionType: 'SELECT_ONE_IN_LOT', + questionText: '', + questionImageUrl: '', + options: [createEmptyOption(), createEmptyOption()], + correctOptionIndex: 0, + }; +} + +async function fileToDataUrl(file: File): Promise { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(new Error('Failed to read image file.')); + reader.readAsDataURL(file); + }); +} + +interface StudentQuestionComposerProps { + isOpen: boolean; + isSubmitting: boolean; + onCancel: () => void; + onSubmit: (payload: StudentQuestionSubmissionPayload) => Promise | void; +} + +export default function StudentQuestionComposer({ + isOpen, + isSubmitting, + onCancel, + onSubmit, +}: StudentQuestionComposerProps) { + const [payload, setPayload] = useState(createInitialPayload); + + useEffect(() => { + if (isOpen) { + setPayload(createInitialPayload()); + } + }, [isOpen]); + + const updateOption = (index: number, patch: Partial) => { + setPayload(current => ({ + ...current, + options: current.options.map((option, optionIndex) => + optionIndex === index ? {...option, ...patch} : option, + ), + })); + }; + + const addOption = () => { + setPayload(current => { + if (current.options.length >= MAX_OPTIONS) { + return current; + } + + return { + ...current, + options: [...current.options, createEmptyOption()], + }; + }); + }; + + const removeOption = (index: number) => { + setPayload(current => { + if (current.options.length <= MIN_OPTIONS) { + return current; + } + + const nextOptions = current.options.filter((_, optionIndex) => optionIndex !== index); + const nextCorrectIndex = + current.correctOptionIndex === index + ? 0 + : current.correctOptionIndex > index + ? current.correctOptionIndex - 1 + : current.correctOptionIndex; + + return { + ...current, + options: nextOptions, + correctOptionIndex: nextCorrectIndex, + }; + }); + }; + + const handleImageUpload = async ( + file: File | undefined, + apply: (imageUrl: string) => void, + ) => { + if (!file) { + return; + } + + if (!file.type.startsWith('image/')) { + toast.error('Please upload a valid image file.'); + return; + } + + if (file.size > MAX_IMAGE_FILE_SIZE) { + toast.error('Image must be 512 KB or smaller.'); + return; + } + + try { + apply(await fileToDataUrl(file)); + } catch (error: any) { + toast.error(error?.message || 'Unable to read image file.'); + } + }; + + const isSubmitDisabled = + isSubmitting || + payload.questionText.trim().length < 10 || + payload.options.length < MIN_OPTIONS || + payload.options.some(option => !option.text?.trim()); + + return ( +
+ {/* Question prompt */} +
+ +
+