diff --git a/.secrets.baseline b/.secrets.baseline index ed54bde8..325d4e9b 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1,6 +1,6 @@ { "exclude": { - "files": null, + "files": "^.secrets.baseline$", "lines": null }, "generated_at": "2025-11-15T04:48:47Z", @@ -602,7 +602,7 @@ } ] }, - "version": "0.13.1+ibm.64.dss", + "version": "0.13.1+ibm.62.dss", "word_list": { "file": null, "hash": null 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 084f2afd..8b76d031 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -312,6 +312,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 @@ -365,6 +366,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 1be57388..57d8da07 100644 --- a/apps/api/src/api/assignment/attempt/attempt.service.ts +++ b/apps/api/src/api/assignment/attempt/attempt.service.ts @@ -795,6 +795,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/attempt/dto/assignment-attempt/get.assignment.attempt.response.dto.ts b/apps/api/src/api/assignment/attempt/dto/assignment-attempt/get.assignment.attempt.response.dto.ts index 9b7efbd7..f9833989 100644 --- a/apps/api/src/api/assignment/attempt/dto/assignment-attempt/get.assignment.attempt.response.dto.ts +++ b/apps/api/src/api/assignment/attempt/dto/assignment-attempt/get.assignment.attempt.response.dto.ts @@ -9,6 +9,7 @@ import { import { Type } from "class-transformer"; import { AttemptQuestionDto } from "src/api/assignment/dto/update.questions.request.dto"; import { Choice } from "../../../question/dto/create.update.question.request.dto"; +import { IsOptional, IsString } from "class-validator"; export class AssignmentAttemptResponseDto { @ApiProperty({ @@ -143,6 +144,15 @@ export class AssignmentAttemptQuestions { }) totalPoints: number; + @ApiPropertyOptional({ + description: "Author comment or note on this question", + type: String, + nullable: true, + }) + @IsOptional() + @IsString() + authorComment?: string | null; + @ApiProperty({ description: "Type of the question.", enum: QuestionType, 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 a4dcd3cd..cc72d396 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], @@ -769,6 +779,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 4e0eea8b..3610de0f 100644 --- a/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts +++ b/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts @@ -141,6 +141,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 b68b6ffb..37ff2d89 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 @@ -326,6 +326,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, @@ -490,6 +491,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, @@ -654,6 +656,7 @@ export class VersionManagementService { data: { assignmentVersionId: restoredVersion.id, questionId: questionVersion.questionId, + authorComment: questionVersion.authorComment ?? null, totalPoints: questionVersion.totalPoints, type: questionVersion.type, responseType: questionVersion.responseType, @@ -975,6 +978,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, @@ -1083,6 +1087,7 @@ export class VersionManagementService { data: { assignmentVersionId: versionId, questionId: question.id, + authorComment: question.authorComment ?? null, totalPoints: question.totalPoints, type: question.type, responseType: question.responseType, @@ -1380,6 +1385,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, @@ -1501,6 +1507,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, @@ -2076,6 +2083,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 52f3f0bd..ab330a85 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/attempt.controller.ts b/apps/api/src/api/attempt/attempt.controller.ts index cf16fb28..71360550 100644 --- a/apps/api/src/api/attempt/attempt.controller.ts +++ b/apps/api/src/api/attempt/attempt.controller.ts @@ -144,6 +144,7 @@ export class AttemptControllerV2 { ): Promise { return this.attemptService.getAssignmentAttempt( Number(assignmentAttemptId), + request.userSession, lang, ); } @@ -156,9 +157,11 @@ export class AttemptControllerV2 { @ApiResponse({ status: 403 }) getLearnerAssignmentAttempt( @Param("attemptId") assignmentAttemptId: number, + @Req() request: UserSessionRequest, ): Promise { return this.attemptService.getLearnerAssignmentAttempt( Number(assignmentAttemptId), + request.userSession, ); } 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/api/src/api/attempt/services/attempt-submission.service.ts b/apps/api/src/api/attempt/services/attempt-submission.service.ts index cb6257e7..51824676 100644 --- a/apps/api/src/api/attempt/services/attempt-submission.service.ts +++ b/apps/api/src/api/attempt/services/attempt-submission.service.ts @@ -56,6 +56,8 @@ import { AttemptValidationService } from "./attempt-validation.service"; import { QuestionResponseService } from "./question-response/question-response.service"; import { QuestionVariantService } from "./question-variant/question-variant.service"; import { TranslationService } from "./translation/translation.service"; +import { Roles } from "src/auth/role/roles.global.guard"; +import { UserSessionMiddleware } from "src/auth/middleware/user.session.middleware"; @Injectable() export class AttemptSubmissionService { @@ -337,6 +339,7 @@ export class AttemptSubmissionService { */ async getLearnerAssignmentAttempt( attemptId: number, + userSession: UserSession, ): Promise { const assignmentAttempt = await this.prisma.assignmentAttempt.findUnique({ where: { id: attemptId }, @@ -384,6 +387,12 @@ export class AttemptSubmissionService { ); } + if (userSession.role === UserRole.LEARNER) { + assignment.questions.map((question) => { + question.authorComment = null; + }); + } + const shouldShowCorrectAnswers = this.shouldShowCorrectAnswers( assignment.currentVersion?.correctAnswerVisibility || "NEVER", assignmentAttempt.grade || 0, @@ -1293,6 +1302,8 @@ export class AttemptSubmissionService { /** * Remove sensitive data from questions */ + + // fpilter out the author comment private removeSensitiveData( questions: AttemptQuestionDto[], assignment: { correctAnswerVisibility: CorrectAnswerVisibility }, @@ -1304,6 +1315,11 @@ export class AttemptSubmissionService { delete question.scoring?.rubrics; } + if (UserRole.LEARNER) { + question.authorComment == null; + } + // if user role learner make quetion null + if (question.choices) { for (const choice of question.choices) { delete choice.points; diff --git a/apps/api/src/api/attempt/services/attempt.service.ts b/apps/api/src/api/attempt/services/attempt.service.ts index e62e5ec5..bc7345c4 100644 --- a/apps/api/src/api/attempt/services/attempt.service.ts +++ b/apps/api/src/api/attempt/services/attempt.service.ts @@ -724,8 +724,12 @@ export class AttemptServiceV2 { */ async getLearnerAssignmentAttempt( attemptId: number, + userSession: UserSession, ): Promise { - return this.submissionService.getLearnerAssignmentAttempt(attemptId); + return this.submissionService.getLearnerAssignmentAttempt( + attemptId, + userSession, + ); } /** @@ -733,6 +737,7 @@ export class AttemptServiceV2 { */ async getAssignmentAttempt( attemptId: number, + userSession: UserSession, language?: string, ): Promise { return this.submissionService.getAssignmentAttempt(attemptId, language); diff --git a/apps/web/app/Helpers/checkDiff.ts b/apps/web/app/Helpers/checkDiff.ts index ac42c855..a8ac50b7 100644 --- a/apps/web/app/Helpers/checkDiff.ts +++ b/apps/web/app/Helpers/checkDiff.ts @@ -268,6 +268,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..e09cd53c 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); @@ -862,6 +873,46 @@ const QuestionWrapper: FC = ({ )} + {!variantMode && + !preview && + typeof isAuthorCommentVisible === "boolean" && + setIsAuthorCommentVisible && + setAuthorComment && + (isAuthorCommentVisible ? ( +
+ +