diff --git a/apps/api/prisma/migrations/20251021142334_add_author_comment_to_question/migration.sql b/apps/api/prisma/migrations/20251021142334_add_author_comment_to_question/migration.sql new file mode 100644 index 00000000..017f89fb --- /dev/null +++ b/apps/api/prisma/migrations/20251021142334_add_author_comment_to_question/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Question" ADD COLUMN "authorComment" TEXT; +ALTER TABLE "QuestionVersion" ADD COLUMN "authorComment" TEXT; + diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index fab58ec1..6fca12d4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -310,6 +310,7 @@ model QuestionVersion { id Int @id @default(autoincrement()) /// Unique identifier for the question version assignmentVersionId Int /// The ID of the assignment version this belongs to assignmentVersion AssignmentVersion @relation(fields: [assignmentVersionId], references: [id], onDelete: Cascade) + authorComment String? /// The comment an author can leave for context on a question questionId Int? /// Reference to original question (null for new questions in version) totalPoints Int /// Points for this question type QuestionType /// Type of question @@ -363,6 +364,7 @@ model Question { totalPoints Int /// Total points that can be scored for the question type QuestionType /// Type of question responseType ResponseType? /// Type of response expected from the learner + authorComment String? /// The comment an author can leave for context on a question question String /// The text of the question variants QuestionVariant[] /// AI-generated variants for this question maxWords Int? /// Optional maximum number of words allowed for a written response type question diff --git a/apps/api/src/api/assignment/attempt/attempt.service.ts b/apps/api/src/api/assignment/attempt/attempt.service.ts index 37537ab5..afbfa80c 100644 --- a/apps/api/src/api/assignment/attempt/attempt.service.ts +++ b/apps/api/src/api/assignment/attempt/attempt.service.ts @@ -793,6 +793,7 @@ export class AttemptServiceV1 { id: originalQ.id, variantId: variant ? variant.id : undefined, question: questionText, + authorComment: originalQ.authorComment ?? null, choices: finalChoices, maxWords, maxCharacters: maxChars, @@ -1124,6 +1125,7 @@ export class AttemptServiceV1 { id: originalQ.id, question: primaryTranslation.translatedText || originalQ?.question, choices: finalChoices, + authorComment: originalQ.authorComment ?? null, translations: variant ? variantTranslations : questionTranslations, maxWords: variant?.maxWords ?? originalQ?.maxWords, maxCharacters: variant?.maxCharacters ?? originalQ?.maxCharacters, diff --git a/apps/api/src/api/assignment/dto/update.questions.request.dto.ts b/apps/api/src/api/assignment/dto/update.questions.request.dto.ts index 6f2dfc91..85888581 100644 --- a/apps/api/src/api/assignment/dto/update.questions.request.dto.ts +++ b/apps/api/src/api/assignment/dto/update.questions.request.dto.ts @@ -204,6 +204,16 @@ export class QuestionDto { @IsBoolean() isDeleted?: boolean; + @ApiPropertyOptional({ + description: "Author comment or note on this question", + type: String, + nullable: true, + }) + @IsOptional() + @IsString() + authorComment?: string | null; + + @ApiProperty({ description: "Grading context question IDs (array of question IDs)", type: [Number], @@ -756,6 +766,15 @@ export class AttemptQuestionDto { @Type(() => Choice) choices?: Choice[]; + @ApiPropertyOptional({ + description: "Author comment or note on this question", + type: String, + nullable: true, + }) + @IsOptional() + @IsString() + authorComment?: string | null; + @ApiPropertyOptional({ description: "Dictionary of translations keyed by language code", diff --git a/apps/api/src/api/assignment/question/dto/create.update.question.request.dto.ts b/apps/api/src/api/assignment/question/dto/create.update.question.request.dto.ts index 18fb9a37..ec788bf0 100644 --- a/apps/api/src/api/assignment/question/dto/create.update.question.request.dto.ts +++ b/apps/api/src/api/assignment/question/dto/create.update.question.request.dto.ts @@ -79,6 +79,15 @@ export class CreateUpdateQuestionRequestDto { @IsEnum(QuestionType) type: QuestionType; + @ApiPropertyOptional({ + description: "Author comment or note on this question", + type: String, + nullable: true, + }) + @IsOptional() + @IsString() + authorComment?: string | null; + @ApiProperty({ description: "The question content.", type: String, diff --git a/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts b/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts index add21496..bd552df9 100644 --- a/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts +++ b/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts @@ -139,6 +139,7 @@ export class AssignmentRepository { assignmentId: result.id, isDeleted: false, totalPoints: qv.totalPoints, + authorComment: qv.authorComment ?? legacy?.authorComment ?? null, type: qv.type, responseType: qv.responseType ?? null, question: qv.question, diff --git a/apps/api/src/api/assignment/v2/repositories/question.repository.ts b/apps/api/src/api/assignment/v2/repositories/question.repository.ts index b786c78d..1c87a239 100644 --- a/apps/api/src/api/assignment/v2/repositories/question.repository.ts +++ b/apps/api/src/api/assignment/v2/repositories/question.repository.ts @@ -81,6 +81,7 @@ export class QuestionRepository { totalPoints: questionData.totalPoints, type: questionData.type, question: questionData.question, + authorComment: questionData.authorComment ?? null, responseType: questionData.responseType, maxWords: questionData.maxWords, maxCharacters: questionData.maxCharacters, @@ -100,6 +101,7 @@ export class QuestionRepository { totalPoints: questionData.totalPoints, type: questionData.type, question: questionData.question, + authorComment: questionData.authorComment ?? null, responseType: questionData.responseType, maxWords: questionData.maxWords, maxCharacters: questionData.maxCharacters, @@ -176,6 +178,7 @@ export class QuestionRepository { type, question, assignmentId, + authorComment, responseType, maxWords, maxCharacters, @@ -223,6 +226,7 @@ export class QuestionRepository { type, question, responseType, + authorComment: authorComment ?? null, maxWords, maxCharacters, randomizedChoices, diff --git a/apps/api/src/api/assignment/v2/services/question.service.ts b/apps/api/src/api/assignment/v2/services/question.service.ts index 2899bb5a..1959f0e6 100644 --- a/apps/api/src/api/assignment/v2/services/question.service.ts +++ b/apps/api/src/api/assignment/v2/services/question.service.ts @@ -204,6 +204,7 @@ export class QuestionService { type: questionDto.type, answer: questionDto.answer ?? false, totalPoints: questionDto.totalPoints ?? 0, + authorComment: questionDto.authorComment ?? null, choices: questionDto.choices, scoring: questionDto.scoring, maxWords: questionDto.maxWords, diff --git a/apps/api/src/api/assignment/v2/services/version-management.service.ts b/apps/api/src/api/assignment/v2/services/version-management.service.ts index d77d368a..c5748ff1 100644 --- a/apps/api/src/api/assignment/v2/services/version-management.service.ts +++ b/apps/api/src/api/assignment/v2/services/version-management.service.ts @@ -325,6 +325,7 @@ export class VersionManagementService { assignmentVersionId: assignmentVersion.id, questionId: question.id, totalPoints: question.totalPoints, + authorComment: question.authorComment ?? null, type: question.type, responseType: question.responseType, question: question.question, @@ -489,6 +490,7 @@ export class VersionManagementService { id: qv.id, questionId: qv.questionId, totalPoints: qv.totalPoints, + authorComment: qv.authorComment, type: qv.type, responseType: qv.responseType, question: qv.question, @@ -652,6 +654,7 @@ export class VersionManagementService { data: { assignmentVersionId: restoredVersion.id, questionId: questionVersion.questionId, + authorComment: questionVersion.authorComment ?? null, totalPoints: questionVersion.totalPoints, type: questionVersion.type, responseType: questionVersion.responseType, @@ -973,6 +976,7 @@ export class VersionManagementService { data: { assignmentVersionId: draftId, questionId: questionData.id || null, + authorComment: questionData.authorComment ?? null, totalPoints: questionData.totalPoints || 0, type: questionData.type, responseType: questionData.responseType, @@ -1080,6 +1084,7 @@ export class VersionManagementService { data: { assignmentVersionId: versionId, questionId: question.id, + authorComment: question.authorComment ?? null, totalPoints: question.totalPoints, type: question.type, responseType: question.responseType, @@ -1376,6 +1381,7 @@ export class VersionManagementService { data: { assignmentVersionId: assignmentVersion.id, questionId: questionData.id || null, + authorComment: questionData.authorComment ?? null, totalPoints: questionData.totalPoints || 0, type: questionData.type, responseType: questionData.responseType, @@ -1497,6 +1503,7 @@ export class VersionManagementService { questions: latestDraft.questionVersions.map((qv) => ({ id: qv.questionId, totalPoints: qv.totalPoints, + authorComment: qv.authorComment, type: qv.type, responseType: qv.responseType, question: qv.question, @@ -2072,6 +2079,7 @@ export class VersionManagementService { data: { assignmentVersionId: assignmentVersion.id, questionId: questionData.id || undefined, + authorComment: questionData.authorComment ?? null, totalPoints: questionData.totalPoints || 0, type: questionData.type, responseType: questionData.responseType, diff --git a/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts b/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts index a4146eb5..8cf49a8a 100644 --- a/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts +++ b/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts @@ -631,6 +631,7 @@ export const createMockQuestion = ( isDeleted: false, videoPresentationConfig: null, liveRecordingConfig: null, + authorComment: null, }; switch (questionType) { diff --git a/apps/api/src/api/attempt/common/utils/attempt-questions-mapper.util.ts b/apps/api/src/api/attempt/common/utils/attempt-questions-mapper.util.ts index 0b63f892..3b1bafe1 100644 --- a/apps/api/src/api/attempt/common/utils/attempt-questions-mapper.util.ts +++ b/apps/api/src/api/attempt/common/utils/attempt-questions-mapper.util.ts @@ -150,6 +150,7 @@ export class AttemptQuestionsMapper { variantId: variant ? variant.id : undefined, question: questionText, choices: finalChoices, + authorComment: null, maxWords, maxCharacters: maxChars, scoring: scoring as ScoringDto, @@ -176,7 +177,7 @@ export class AttemptQuestionsMapper { if (variantQ) { return variantQ; } - return { ...originalQ, variantId: undefined }; + return { ...originalQ, variantId: undefined, authorComment: null }; }); const questionsWithResponses = this.constructQuestionsWithResponses( @@ -303,6 +304,7 @@ export class AttemptQuestionsMapper { question: primaryTranslation.translatedText, choices: sanitizedChoices, translations: sanitizedTranslations, + authorComment: null, maxWords: variant?.maxWords ?? originalQ?.maxWords, maxCharacters: variant?.maxCharacters ?? originalQ?.maxCharacters, scoring: @@ -348,6 +350,7 @@ export class AttemptQuestionsMapper { translationForLanguage?.translatedText || originalQ.question, choices: sanitizedChoices, translations: sanitizedTranslations, + authorComment: null, maxWords: originalQ.maxWords, maxCharacters: originalQ.maxCharacters, scoring: originalQ.scoring, diff --git a/apps/web/app/Helpers/checkDiff.ts b/apps/web/app/Helpers/checkDiff.ts index 99d9afb4..bc6ebdf9 100644 --- a/apps/web/app/Helpers/checkDiff.ts +++ b/apps/web/app/Helpers/checkDiff.ts @@ -266,6 +266,16 @@ export function useChangesSummary(): string { diffs.push(`Updated max characters for question ${question.id}.`); } + const normalizedAuthorComment = (question.authorComment ?? "").trim(); + const normalizedOriginalAuthorComment = ( + originalQuestion.authorComment ?? "" + ).trim(); + if ( + !safeCompare(normalizedAuthorComment, normalizedOriginalAuthorComment) + ) { + diffs.push(`Updated the author comment for question ${question.id}.`); + } + if ( !safeCompare( question.videoPresentationConfig, diff --git a/apps/web/app/author/(components)/(questionComponents)/QuestionWrapper.tsx b/apps/web/app/author/(components)/(questionComponents)/QuestionWrapper.tsx index e9c71fd9..02fbb83f 100644 --- a/apps/web/app/author/(components)/(questionComponents)/QuestionWrapper.tsx +++ b/apps/web/app/author/(components)/(questionComponents)/QuestionWrapper.tsx @@ -16,13 +16,14 @@ import { expandMarkingRubric, generateRubric } from "@/lib/talkToBackend"; import { useAuthorStore, useQuestionStore } from "@/stores/author"; import MarkdownEditor from "@components/MarkDownEditor"; import { InformationCircleIcon } from "@heroicons/react/24/outline"; -import { ArrowDownIcon, PlusIcon } from "@heroicons/react/24/solid"; import React, { FC, useEffect, useRef, useState, type ComponentPropsWithoutRef, + type Dispatch, + type SetStateAction, } from "react"; import { toast } from "sonner"; import MultipleAnswerSection from "../Questions/QuestionTypes/MultipleAnswerSection"; @@ -294,6 +295,11 @@ interface QuestionWrapperProps extends ComponentPropsWithoutRef<"div"> { variantMode: boolean; responseType: ResponseType; variantId?: number; + authorComment?: string; + setAuthorComment?: Dispatch>; + onAuthorCommentBlur?: () => void; + isAuthorCommentVisible?: boolean; + setIsAuthorCommentVisible?: Dispatch>; } const QuestionWrapper: FC = ({ @@ -314,6 +320,11 @@ const QuestionWrapper: FC = ({ variantMode, variantId, responseType, + authorComment, + setAuthorComment, + onAuthorCommentBlur, + isAuthorCommentVisible, + setIsAuthorCommentVisible, }) => { const [localQuestionTitle, setLocalQuestionTitle] = useState(questionTitle); @@ -810,10 +821,12 @@ const QuestionWrapper: FC = ({ } }; + return (
{toggleTitle && !preview ? (
@@ -848,11 +861,10 @@ const QuestionWrapper: FC = ({ } > {localQuestionTitle?.trim() === "" ? "Enter question here" @@ -862,6 +874,48 @@ const QuestionWrapper: FC = ({
)} + {!variantMode && + !preview && + typeof isAuthorCommentVisible === "boolean" && + setIsAuthorCommentVisible && + setAuthorComment && ( + isAuthorCommentVisible ? ( +
+ +