From c3afa67eb2c11072c085ffbfbf3bfe9ceef94825 Mon Sep 17 00:00:00 2001 From: behzad-janjua Date: Tue, 28 Oct 2025 16:43:25 -0400 Subject: [PATCH 1/5] feat: persisting author comment box --- .../migration.sql | 4 + apps/api/prisma/schema.prisma | 2 + .../api/assignment/attempt/attempt.service.ts | 2 + .../dto/update.questions.request.dto.ts | 19 ++ .../dto/create.update.question.request.dto.ts | 9 + .../v2/repositories/assignment.repository.ts | 1 + .../v2/repositories/question.repository.ts | 4 + .../v2/services/question.service.ts | 1 + .../v2/services/version-management.service.ts | 8 + .../utils/attempt-questions-mapper.util.ts | 5 +- .../AuthorQuestionsPage/Question.tsx | 184 ++++++++++-------- apps/web/config/types.ts | 6 + apps/web/stores/author.ts | 8 +- 13 files changed, 175 insertions(+), 78 deletions(-) create mode 100644 apps/api/prisma/migrations/20251021142334_add_author_comment_to_question/migration.sql 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 412ab440..d524ca8e 100644 --- a/apps/api/src/api/assignment/attempt/attempt.service.ts +++ b/apps/api/src/api/assignment/attempt/attempt.service.ts @@ -794,6 +794,7 @@ export class AttemptServiceV1 { id: originalQ.id, variantId: variant ? variant.id : undefined, question: questionText, + authorComment: originalQ.authorComment ?? null, choices: finalChoices, maxWords, maxCharacters: maxChars, @@ -1126,6 +1127,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 1e290b12..f2195d50 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], @@ -758,6 +768,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 4ff7b585..97060b96 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 6d18640e..fe2700d3 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, @@ -101,6 +102,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, @@ -178,6 +180,7 @@ export class QuestionRepository { type, question, assignmentId, + authorComment, responseType, maxWords, maxCharacters, @@ -225,6 +228,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 f0e4430b..c9627094 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 a7ebd8a9..4f96de9c 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 @@ -342,6 +342,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, @@ -512,6 +513,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, @@ -680,6 +682,7 @@ export class VersionManagementService { data: { assignmentVersionId: restoredVersion.id, questionId: questionVersion.questionId, + authorComment: questionVersion.authorComment ?? null, totalPoints: questionVersion.totalPoints, type: questionVersion.type, responseType: questionVersion.responseType, @@ -1019,6 +1022,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, @@ -1130,6 +1134,7 @@ export class VersionManagementService { data: { assignmentVersionId: versionId, questionId: question.id, + authorComment: question.authorComment ?? null, totalPoints: question.totalPoints, type: question.type, responseType: question.responseType, @@ -1444,6 +1449,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, @@ -1569,6 +1575,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, @@ -2175,6 +2182,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/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/author/(components)/AuthorQuestionsPage/Question.tsx b/apps/web/app/author/(components)/AuthorQuestionsPage/Question.tsx index 5cd4823d..e5da43d8 100644 --- a/apps/web/app/author/(components)/AuthorQuestionsPage/Question.tsx +++ b/apps/web/app/author/(components)/AuthorQuestionsPage/Question.tsx @@ -191,6 +191,11 @@ const Question: FC = ({ if (params.maxWordCount !== undefined) { updatedData.maxWords = params.maxWordCount; } + + if (params.authorComment !== undefined) { + updatedData.authorComment = params.authorComment; + } + useAuthorStore.getState().modifyQuestion(questionId, { ...question, ...updatedData, @@ -218,6 +223,9 @@ const Question: FC = ({ ? params.rubrics : (question.scoring?.rubrics ?? []), }, + + authorComment: params.authorComment ?? question.authorComment, + }; useAuthorStore.getState().modifyQuestion(questionId, updatedQuestion); @@ -239,37 +247,37 @@ const Question: FC = ({ choices: questionType === "MULTIPLE_CORRECT" || questionType === "SINGLE_CORRECT" ? [ + { + choice: "", + points: 1, + feedback: "", + isCorrect: true, + }, + { + choice: "", + points: 0, + feedback: "", + isCorrect: false, + }, + ] + : undefined, + scoring: + questionType === "TEXT" || questionType === "URL" + ? { + type: "CRITERIA_BASED", + criteria: [ { - choice: "", + id: 1, + description: "", points: 1, - feedback: "", - isCorrect: true, }, { - choice: "", + id: 2, + description: "", points: 0, - feedback: "", - isCorrect: false, }, - ] - : undefined, - scoring: - questionType === "TEXT" || questionType === "URL" - ? { - type: "CRITERIA_BASED", - criteria: [ - { - id: 1, - description: "", - points: 1, - }, - { - id: 2, - description: "", - points: 0, - }, - ], - } + ], + } : undefined, createdAt: new Date().toISOString(), variantType: "REWORDED", @@ -450,16 +458,14 @@ const Question: FC = ({ if (variantId) { const randomizedMode = toggleRandomizedChoicesMode(questionId, variantId); toast.info( - `Randomized choice order for question ${questionIndex} ${ - variantId ? `: variant ${Index}` : "" + `Randomized choice order for question ${questionIndex} ${variantId ? `: variant ${Index}` : "" } has been ${randomizedMode ? "ENABLED" : "DISABLED"}`, ); return; } const randomizedMode = toggleRandomizedChoicesMode(questionId); toast.info( - `Randomized choice order for question number ${questionIndex} has been ${ - randomizedMode ? "ENABLED" : "DISABLED" + `Randomized choice order for question number ${questionIndex} has been ${randomizedMode ? "ENABLED" : "DISABLED" }`, ); }; @@ -468,9 +474,42 @@ const Question: FC = ({ router.push(`/author/${question.assignmentId}/questions`); }; + // Local state for author comment + const [localAuthorComment, setLocalAuthorComment] = useState(question.authorComment ?? ""); + + // Keep local state synced if question changes elsewhere + useEffect(() => { + setLocalAuthorComment(question.authorComment ?? ""); + }, [question.authorComment]); + return (
+ {/* 🔹 Author Comment field (top of every question) */} + {!preview && ( +
+ +