diff --git a/.secrets.baseline b/.secrets.baseline index c51f2ce0..790eaa95 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": null, "lines": null }, - "generated_at": "2025-10-03T10:22:15Z", + "generated_at": "2025-10-09T10:00:02Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -306,7 +306,7 @@ { "hashed_secret": "d3ecb0d890368d7659ee54010045b835dacb8efe", "is_verified": false, - "line_number": 1562, + "line_number": 1430, "type": "Secret Keyword", "verified_result": null } @@ -333,14 +333,14 @@ { "hashed_secret": "1c2b0d17c738509518ecc6efa233ee6c10e724f2", "is_verified": false, - "line_number": 225, + "line_number": 224, "type": "Basic Auth Credentials", "verified_result": null }, { "hashed_secret": "f6b2b77f83ae6122acb0954c6502cf87b2c097c3", "is_verified": false, - "line_number": 265, + "line_number": 264, "type": "Secret Keyword", "verified_result": null } diff --git a/apps/api/prisma/migrations/20251009084928_adding_attempts_before_cooldown/migration.sql b/apps/api/prisma/migrations/20251009084928_adding_attempts_before_cooldown/migration.sql new file mode 100644 index 00000000..acc612b5 --- /dev/null +++ b/apps/api/prisma/migrations/20251009084928_adding_attempts_before_cooldown/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Assignment" ADD COLUMN "attemptsBeforeCoolDown" INTEGER DEFAULT 1, +ADD COLUMN "retakeAttemptCoolDownMinutes" INTEGER DEFAULT 5; + +-- AlterTable +ALTER TABLE "AssignmentDraft" ADD COLUMN "attemptsBeforeCoolDown" INTEGER DEFAULT 1, +ADD COLUMN "retakeAttemptCoolDownMinutes" INTEGER DEFAULT 5; + +-- AlterTable +ALTER TABLE "AssignmentVersion" ADD COLUMN "attemptsBeforeCoolDown" INTEGER DEFAULT 1, +ADD COLUMN "retakeAttemptCoolDownMinutes" INTEGER DEFAULT 5; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index accfcf3c..e80eb145 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -212,6 +212,8 @@ model Assignment { instructions String? /// Instructions for the assignment gradingCriteriaOverview String? /// Grading Criteria for the assignment timeEstimateMinutes Int? /// Estimated time it will take to complete the assignment in minutes + attemptsBeforeCoolDown Int? @default(1) /// How many attempts someone has before they have to go through a cooldown period, cooldown starts after first attempt + retakeAttemptCoolDownMinutes Int? @default(5) /// How long before someone can make another attempt, defaulted to 5 minutes type AssignmentType /// Type of assignment graded Boolean? @default(false) /// Is the assignment graded or not numAttempts Int? @default(-1) /// Max number of times a learner can attempt the assignment @@ -229,7 +231,7 @@ model Assignment { showAssignmentScore Boolean @default(true) /// Should the assignment score be shown to the learner after its submission showQuestionScore Boolean @default(true) /// Should the question score be shown to the learner after its submission showSubmissionFeedback Boolean @default(true) /// Should the AI provide feedback when the learner submits a question - showQuestions Boolean @default(true) /// Should the questions be shown to the learner + showQuestions Boolean @default(true) /// Should the questions be shown to the learner showCorrectAnswer Boolean @default(true) /// Should the correct answer be shown to the learner after its submission updatedAt DateTime @default(now()) @updatedAt /// The DateTime at which the assignment was last updated languageCode String? /// The language code for the assignment @@ -262,6 +264,8 @@ model AssignmentVersion { instructions String? /// Instructions for this version gradingCriteriaOverview String? /// Grading criteria for this version timeEstimateMinutes Int? /// Time estimate for this version + attemptsBeforeCoolDown Int? @default(1) /// How many attempts someone has before they have to go through a cooldown period, cooldown starts after first attempt + retakeAttemptCoolDownMinutes Int? @default(5) /// How long before someone can make another attempt, defaulted to 5 minutes type AssignmentType /// Type of assignment graded Boolean? @default(false) /// Is the assignment graded numAttempts Int? @default(-1) /// Max attempts allowed @@ -595,6 +599,8 @@ model AssignmentDraft { instructions String? /// Instructions for this draft gradingCriteriaOverview String? /// Grading criteria for this draft timeEstimateMinutes Int? /// Time estimate for this draft + attemptsBeforeCoolDown Int? @default(1) /// How many attempts someone has before they have to go through a cooldown period, cooldown starts after first attempt + retakeAttemptCoolDownMinutes Int? @default(5) /// How long before someone can make another attempt, defaulted to 5 minutes type AssignmentType /// Type of assignment graded Boolean? @default(false) /// Is the assignment graded numAttempts Int? @default(-1) /// Max attempts allowed diff --git a/apps/api/src/api/assignment/attempt/api-exceptions/exceptions.ts b/apps/api/src/api/assignment/attempt/api-exceptions/exceptions.ts index 81ae5085..867058ff 100644 --- a/apps/api/src/api/assignment/attempt/api-exceptions/exceptions.ts +++ b/apps/api/src/api/assignment/attempt/api-exceptions/exceptions.ts @@ -10,3 +10,5 @@ export const IN_PROGRESS_SUBMISSION_EXCEPTION = "A attempt is already in progress and has not expired."; export const TIME_RANGE_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE = "You have exceeded the allowed number of attempts within the specified time range."; +export const IN_COOLDOWN_PERIOD = + "You must wait some time before creating a new attempt for this assignment"; diff --git a/apps/api/src/api/assignment/attempt/attempt.controller.ts b/apps/api/src/api/assignment/attempt/attempt.controller.ts index 0434ad1a..58eb44a6 100644 --- a/apps/api/src/api/assignment/attempt/attempt.controller.ts +++ b/apps/api/src/api/assignment/attempt/attempt.controller.ts @@ -29,6 +29,7 @@ import { import { Roles } from "../../../auth/role/roles.global.guard"; import { GRADE_SUBMISSION_EXCEPTION, + IN_COOLDOWN_PERIOD, MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, SUBMISSION_DEADLINE_EXCEPTION_MESSAGE, } from "./api-exceptions/exceptions"; @@ -77,6 +78,11 @@ export class AttemptControllerV1 { type: String, description: MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, }) + @ApiResponse({ + status: 429, + type: String, + description: IN_COOLDOWN_PERIOD, + }) @ApiResponse({ status: 403 }) createAssignmentAttempt( @Param("assignmentId") assignmentId: number, @@ -150,6 +156,11 @@ export class AttemptControllerV1 { type: String, description: SUBMISSION_DEADLINE_EXCEPTION_MESSAGE, }) + @ApiResponse({ + status: 429, + type: String, + description: IN_COOLDOWN_PERIOD, + }) @ApiResponse({ status: 500, type: String, diff --git a/apps/api/src/api/assignment/attempt/attempt.service.ts b/apps/api/src/api/assignment/attempt/attempt.service.ts index 6726f1e9..574946bf 100644 --- a/apps/api/src/api/assignment/attempt/attempt.service.ts +++ b/apps/api/src/api/assignment/attempt/attempt.service.ts @@ -47,6 +47,7 @@ import { MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, SUBMISSION_DEADLINE_EXCEPTION_MESSAGE, TIME_RANGE_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, + IN_COOLDOWN_PERIOD, } from "./api-exceptions/exceptions"; import { BaseAssignmentAttemptResponseDto } from "./dto/assignment-attempt/base.assignment.attempt.response.dto"; import { @@ -1474,6 +1475,7 @@ export class AttemptServiceV1 { assignment: LearnerGetAssignmentResponseDto, userSession: UserSession, ): Promise { + const now = new Date(); const timeRangeStartDate = this.calculateTimeRangeStartDate(assignment); const attempts = await this.prisma.assignmentAttempt.findMany({ @@ -1483,36 +1485,28 @@ export class AttemptServiceV1 { OR: [ { submitted: false, - expiresAt: { - gte: new Date(), - }, + expiresAt: { gte: now }, }, { submitted: false, expiresAt: undefined, }, { - createdAt: { - gte: timeRangeStartDate, - lte: new Date(), - }, + createdAt: { gte: timeRangeStartDate, lte: now }, }, ], }, + orderBy: { createdAt: "desc" }, }); const ongoingAttempts = attempts.filter( - (sub) => - !sub.submitted && - (sub.expiresAt >= new Date() || sub.expiresAt === null), - ); - - const attemptsInTimeRange = attempts.filter( - (sub) => - sub.createdAt >= timeRangeStartDate && sub.createdAt <= new Date(), + (sub) => !sub.submitted && (!sub.expiresAt || sub.expiresAt >= now), ); if (ongoingAttempts.length > 0) { throw new UnprocessableEntityException(IN_PROGRESS_SUBMISSION_EXCEPTION); } + const attemptsInTimeRange = attempts.filter( + (sub) => sub.createdAt >= timeRangeStartDate && sub.createdAt <= now, + ); if ( assignment.attemptsPerTimeRange && @@ -1523,16 +1517,34 @@ export class AttemptServiceV1 { ); } if (assignment.numAttempts !== null && assignment.numAttempts !== -1) { - const attemptCount = await this.countUserAttempts( + const totalAttempts = await this.countUserAttempts( userSession.userId, assignment.id, ); - if (attemptCount >= assignment.numAttempts) { + if (totalAttempts >= assignment.numAttempts) { throw new UnprocessableEntityException( MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, ); } + + const attemptsBeforeCoolDown = assignment.attemptsBeforeCoolDown ?? 1; + const cooldownMinutes = assignment.retakeAttemptCoolDownMinutes ?? 0; + + if ( + attemptsBeforeCoolDown > 0 && + totalAttempts >= attemptsBeforeCoolDown + ) { + const latestAttempt = attemptsInTimeRange[0]; + if (latestAttempt?.expiresAt) { + const nextEligibleTime = + new Date(latestAttempt.expiresAt).getTime() + + cooldownMinutes * 60_000; + if (now.getTime() < nextEligibleTime) { + throw new UnprocessableEntityException(IN_COOLDOWN_PERIOD); + } + } + } } } diff --git a/apps/api/src/api/assignment/dto/replace.assignment.request.dto.ts b/apps/api/src/api/assignment/dto/replace.assignment.request.dto.ts index c48cd84e..8e9eb0bb 100644 --- a/apps/api/src/api/assignment/dto/replace.assignment.request.dto.ts +++ b/apps/api/src/api/assignment/dto/replace.assignment.request.dto.ts @@ -67,6 +67,26 @@ export class ReplaceAssignmentRequestDto { @IsInt() numAttempts: number | null; + @ApiProperty({ + description: + "The number of attempts before learners must wait for some period of time to retry (null means never waiting to retry)", + type: Number, + required: false, + }) + @IsOptional() + @IsInt() + attemptsBeforeCoolDown: number | null; + + @ApiProperty({ + description: + "The amount of time learners must wait to retry in minutes (null means right away)", + type: Number, + required: false, + }) + @IsOptional() + @IsInt() + retakeAttemptCoolDownMinutes: number | null; + @ApiProperty({ description: "The allotted time for the assignment. (null means unlimited time)", diff --git a/apps/api/src/api/assignment/dto/update.assignment.request.dto.ts b/apps/api/src/api/assignment/dto/update.assignment.request.dto.ts index 3e98699e..13abd426 100644 --- a/apps/api/src/api/assignment/dto/update.assignment.request.dto.ts +++ b/apps/api/src/api/assignment/dto/update.assignment.request.dto.ts @@ -79,6 +79,26 @@ export class UpdateAssignmentRequestDto { @IsInt() numAttempts: number | null; + @ApiProperty({ + description: + "The number of attempts before learners must wait for some period of time to retry (null means never waiting to retry)", + type: Number, + required: false, + }) + @IsOptional() + @IsInt() + attemptsBeforeCoolDown: number | null; + + @ApiProperty({ + description: + "The amount of time learners must wait to retry in minutes (null means right away)", + type: Number, + required: false, + }) + @IsOptional() + @IsInt() + retakeAttemptCoolDownMinutes: number | null; + @ApiProperty({ description: "The allotted time for the assignment. (null means unlimited time)", 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 756d20aa..7477ae4d 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 @@ -424,6 +424,26 @@ export class UpdateAssignmentQuestionsDto { @IsInt() numAttempts: number | null; + @ApiProperty({ + description: + "The number of attempts before learners must wait for some period of time to retry (null means never waiting to retry)", + type: Number, + required: false, + }) + @IsOptional() + @IsInt() + attemptsBeforeCoolDown: number | null; + + @ApiProperty({ + description: + "The amount of time learners must wait to retry in minutes (null means right away)", + type: Number, + required: false, + }) + @IsOptional() + @IsInt() + retakeAttemptCoolDownMinutes: number | null; + @ApiProperty({ description: "The allotted time for the assignment. (null means unlimited time)", diff --git a/apps/api/src/api/assignment/v1/services/assignment.service.ts b/apps/api/src/api/assignment/v1/services/assignment.service.ts index 400c588e..78fe3b1c 100644 --- a/apps/api/src/api/assignment/v1/services/assignment.service.ts +++ b/apps/api/src/api/assignment/v1/services/assignment.service.ts @@ -187,6 +187,8 @@ export class AssignmentServiceV1 { type: version.type, graded: version.graded, numAttempts: version.numAttempts, + attemptsBeforeCoolDown: version.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: version.retakeAttemptCoolDownMinutes, allotedTimeMinutes: version.allotedTimeMinutes, attemptsPerTimeRange: version.attemptsPerTimeRange, attemptsTimeRangeHours: version.attemptsTimeRangeHours, @@ -599,6 +601,8 @@ export class AssignmentServiceV1 { instructions, gradingCriteriaOverview, numAttempts, + attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, passingGrade, displayOrder, graded, @@ -695,6 +699,8 @@ export class AssignmentServiceV1 { instructions, gradingCriteriaOverview, numAttempts, + attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, passingGrade, displayOrder, graded, @@ -1704,6 +1710,8 @@ export class AssignmentServiceV1 { return { instructions: undefined, numAttempts: undefined, + attemptsBeforeCoolDown: undefined, + retakeAttemptCoolDownMinutes: undefined, allotedTimeMinutes: undefined, attemptsPerTimeRange: undefined, attemptsTimeRangeHours: undefined, 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 b70ea837..30cdc959 100644 --- a/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts +++ b/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts @@ -1,15 +1,10 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { - Assignment, - AssignmentQuestionDisplayOrder, - Question, - QuestionVariant, -} from "@prisma/client"; +import { Assignment, Question, QuestionVariant } from "@prisma/client"; import { UserRole, UserSession, -} from "src/auth/interfaces/user.session.interface"; -import { PrismaService } from "src/prisma.service"; +} from "../../../../auth/interfaces/user.session.interface"; +import { PrismaService } from "../../../../prisma.service"; import { AssignmentResponseDto, GetAssignmentResponseDto, @@ -23,23 +18,75 @@ import { VideoPresentationConfig, } from "../../dto/update.questions.request.dto"; -/** - * Repository for Assignment data access operations - */ +/** Fields we want to merge from activeVersion → assignment → defaults */ +const FIELDS = [ + "name", + "introduction", + "instructions", + "gradingCriteriaOverview", + "timeEstimateMinutes", + "attemptsBeforeCoolDown", + "retakeAttemptCoolDownMinutes", + "type", + "graded", + "numAttempts", + "allotedTimeMinutes", + "attemptsPerTimeRange", + "attemptsTimeRangeHours", + "passingGrade", + "displayOrder", + "questionDisplay", + "numberOfQuestionsPerAttempt", + "published", + "showAssignmentScore", + "showQuestionScore", + "showSubmissionFeedback", + "showQuestions", + "languageCode", +] as const; + +type FieldKey = (typeof FIELDS)[number]; + +/** Typed defaults for overlapping fields */ +const DEFAULTS: Partial> = { + attemptsBeforeCoolDown: 1, + retakeAttemptCoolDownMinutes: 5, + passingGrade: 50, + questionDisplay: "ONE_PER_PAGE", + graded: false, + numAttempts: -1, + showAssignmentScore: true, + showQuestionScore: true, + showSubmissionFeedback: true, + showQuestions: true, +}; + +/** Safe coalescer */ +function prefer(...vals: Array): T | null { + for (const v of vals) if (v !== undefined && v !== null) return v; + return null; +} + +/** Merge whitelisted fields from primary → secondary → defaults */ +function mergeFields( + keys: readonly FieldKey[], + primary?: Partial>, + secondary?: Partial>, + defaults?: Partial>, +): Partial> { + const out: Partial> = {}; + for (const k of keys) { + out[k] = prefer(primary?.[k], secondary?.[k], defaults?.[k]); + } + return out; +} + @Injectable() export class AssignmentRepository { private readonly logger = new Logger(AssignmentRepository.name); constructor(private readonly prisma: PrismaService) {} - /** - * Find assignment by ID with related data - * - * @param id - Assignment ID - * @param userSession - Optional user session for role-based access - * @returns Assignment data formatted based on user role - */ - async findById( id: number, userSession?: UserSession, @@ -49,7 +96,15 @@ export class AssignmentRepository { const result = await this.prisma.assignment.findUnique({ where: { id }, include: { + currentVersion: { include: { questionVersions: true } }, + versions: { + where: { isActive: true }, + include: { questionVersions: true }, + orderBy: { id: "desc" }, + take: 1, + }, questions: { + where: { isDeleted: false }, include: { variants: true }, }, }, @@ -59,7 +114,66 @@ export class AssignmentRepository { throw new NotFoundException(`Assignment with Id ${id} not found.`); } - const processedAssignment = this.processAssignmentData(result); + const activeVersion = + (result.currentVersion?.isActive ? result.currentVersion : null) ?? + (result.versions?.length ? result.versions[0] : null); + + let processedAssignment: Assignment & { questions: QuestionDto[] }; + + if (activeVersion) { + const mappedQuestions: (Question & { variants: QuestionVariant[] })[] = [ + ...(activeVersion.questionVersions ?? []), + ] + .sort((a, b) => { + const ao = a.displayOrder ?? 0; + const bo = b.displayOrder ?? 0; + return ao === bo ? a.id - b.id : ao - bo; + }) + .map((qv) => { + const legacy = qv.questionId + ? result.questions.find((q) => q.id === qv.questionId) + : undefined; + + const q: Question & { variants: QuestionVariant[] } = { + id: qv.questionId ?? -qv.id, + assignmentId: result.id, + isDeleted: false, + totalPoints: qv.totalPoints, + type: qv.type, + responseType: qv.responseType ?? null, + question: qv.question, + maxWords: qv.maxWords ?? null, + scoring: qv.scoring ?? null, + choices: qv.choices ?? null, + randomizedChoices: qv.randomizedChoices ?? null, + answer: qv.answer ?? null, + gradingContextQuestionIds: qv.gradingContextQuestionIds ?? [], + maxCharacters: qv.maxCharacters ?? null, + videoPresentationConfig: qv.videoPresentationConfig ?? null, + liveRecordingConfig: qv.liveRecordingConfig ?? null, + variants: legacy?.variants ?? [], + }; + return q; + }); + + const merged = mergeFields(FIELDS, activeVersion, result, DEFAULTS); + + const composed: Assignment & { + questions: (Question & { variants: QuestionVariant[] })[]; + } = { + ...(result as Assignment), + ...(merged as Partial), + questionOrder: + (activeVersion.questionOrder?.length + ? activeVersion.questionOrder + : result.questionOrder) ?? [], + questions: mappedQuestions, + }; + + processedAssignment = this.processAssignmentData(composed); + } else { + processedAssignment = this.processAssignmentData(result); + } if (isLearner) { return { @@ -76,7 +190,7 @@ export class AssignmentRepository { processedAssignment.questions?.map((q) => ({ ...q, alreadyInBackend: true, - })) || [], + })) ?? [], } as unknown as GetAssignmentResponseDto; } @@ -282,7 +396,9 @@ export class AssignmentRepository { allotedTimeMinutes: undefined, attemptsPerTimeRange: undefined, attemptsTimeRangeHours: undefined, - displayOrder: undefined as unknown as AssignmentQuestionDisplayOrder, + attemptsBeforeCoolDown: undefined, + retakeAttemptCoolDownMinutes: undefined, + displayOrder: undefined, }; } } diff --git a/apps/api/src/api/assignment/v2/services/assignment.service.ts b/apps/api/src/api/assignment/v2/services/assignment.service.ts index 9a7b1cbb..5282c052 100644 --- a/apps/api/src/api/assignment/v2/services/assignment.service.ts +++ b/apps/api/src/api/assignment/v2/services/assignment.service.ts @@ -223,6 +223,8 @@ export class AssignmentServiceV2 { instructions: updateDto.instructions, gradingCriteriaOverview: updateDto.gradingCriteriaOverview, numAttempts: updateDto.numAttempts, + attemptsBeforeCoolDown: updateDto.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: updateDto.retakeAttemptCoolDownMinutes, passingGrade: updateDto.passingGrade, displayOrder: updateDto.displayOrder, graded: updateDto.graded, diff --git a/apps/api/src/api/assignment/v2/services/draft-management.service.ts b/apps/api/src/api/assignment/v2/services/draft-management.service.ts index a2d9f858..11e6c794 100644 --- a/apps/api/src/api/assignment/v2/services/draft-management.service.ts +++ b/apps/api/src/api/assignment/v2/services/draft-management.service.ts @@ -28,6 +28,8 @@ export interface SaveDraftDto { type: string; graded: boolean; numAttempts: number; + attemptsBeforeCoolDown: number; + retakeAttemptCoolDownMinutes: number; allotedTimeMinutes: number; attemptsPerTimeRange: number; attemptsTimeRangeHours: number; @@ -61,13 +63,13 @@ export class DraftManagementService { constructor( private readonly prisma: PrismaService, - @Inject(WINSTON_MODULE_PROVIDER) private parentLogger: Logger, + @Inject(WINSTON_MODULE_PROVIDER) private parentLogger: Logger ) { this.logger = parentLogger.child({ context: "DraftManagementService" }); } private parseDisplayOrder( - value: any, + value: any ): AssignmentQuestionDisplayOrder | undefined { if (value === "DEFINED" || value === "RANDOM") { return value as AssignmentQuestionDisplayOrder; @@ -85,7 +87,7 @@ export class DraftManagementService { async saveDraft( assignmentId: number, saveDraftDto: SaveDraftDto, - userSession: UserSession, + userSession: UserSession ): Promise { this.logger.info(`Saving draft for assignment ${assignmentId}`, { userId: userSession.userId, @@ -132,6 +134,12 @@ export class DraftManagementService { graded: saveDraftDto.assignmentData?.graded ?? assignment.graded, numAttempts: saveDraftDto.assignmentData?.numAttempts ?? assignment.numAttempts, + attemptsBeforeCoolDown: + saveDraftDto.assignmentData?.attemptsBeforeCoolDown ?? + assignment.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: + saveDraftDto.assignmentData?.retakeAttemptCoolDownMinutes ?? + assignment.retakeAttemptCoolDownMinutes, allotedTimeMinutes: saveDraftDto.assignmentData?.allotedTimeMinutes ?? assignment.allotedTimeMinutes, @@ -149,7 +157,7 @@ export class DraftManagementService { assignment.displayOrder, questionDisplay: this.parseQuestionDisplay( - saveDraftDto.assignmentData?.questionDisplay, + saveDraftDto.assignmentData?.questionDisplay ) ?? assignment.questionDisplay, numberOfQuestionsPerAttempt: saveDraftDto.assignmentData?.numberOfQuestionsPerAttempt ?? @@ -184,7 +192,7 @@ export class DraftManagementService { { draftId: assignmentDraft.id, userId: userSession.userId, - }, + } ); return { @@ -202,7 +210,7 @@ export class DraftManagementService { async updateDraft( draftId: number, saveDraftDto: SaveDraftDto, - userSession: UserSession, + userSession: UserSession ): Promise { this.logger.info(`Updating draft ${draftId}`, { userId: userSession.userId, @@ -248,6 +256,16 @@ export class DraftManagementService { ...(saveDraftDto.assignmentData?.numAttempts !== undefined && { numAttempts: saveDraftDto.assignmentData.numAttempts, }), + ...(saveDraftDto.assignmentData?.attemptsBeforeCoolDown !== + undefined && { + attemptsBeforeCoolDown: + saveDraftDto.assignmentData.attemptsBeforeCoolDown, + }), + ...(saveDraftDto.assignmentData?.retakeAttemptCoolDownMinutes !== + undefined && { + retakeAttemptCoolDownMinutes: + saveDraftDto.assignmentData.retakeAttemptCoolDownMinutes, + }), ...(saveDraftDto.assignmentData?.allotedTimeMinutes !== undefined && { allotedTimeMinutes: saveDraftDto.assignmentData.allotedTimeMinutes, }), @@ -265,12 +283,12 @@ export class DraftManagementService { }), ...(saveDraftDto.assignmentData?.displayOrder !== undefined && { displayOrder: this.parseDisplayOrder( - saveDraftDto.assignmentData.displayOrder, + saveDraftDto.assignmentData.displayOrder ), }), ...(saveDraftDto.assignmentData?.questionDisplay !== undefined && { questionDisplay: this.parseQuestionDisplay( - saveDraftDto.assignmentData.questionDisplay, + saveDraftDto.assignmentData.questionDisplay ), }), ...(saveDraftDto.assignmentData?.numberOfQuestionsPerAttempt !== @@ -317,7 +335,7 @@ export class DraftManagementService { async listUserDrafts( assignmentId: number, - userSession: UserSession, + userSession: UserSession ): Promise { // Verify assignment access // await this.verifyAssignmentAccess(assignmentId, userSession); @@ -344,7 +362,7 @@ export class DraftManagementService { async getDraft( draftId: number, - userSession: UserSession, + userSession: UserSession ): Promise<{ id: number; name: string; @@ -355,6 +373,8 @@ export class DraftManagementService { type: string; graded: boolean; numAttempts: number; + attemptsBeforeCoolDown: number; + retakeAttemptCoolDownMinutes: number; allotedTimeMinutes: number; attemptsPerTimeRange: number; attemptsTimeRangeHours: number; @@ -397,6 +417,8 @@ export class DraftManagementService { type: draft.type, graded: draft.graded, numAttempts: draft.numAttempts, + attemptsBeforeCoolDown: draft.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: draft.retakeAttemptCoolDownMinutes, allotedTimeMinutes: draft.allotedTimeMinutes, attemptsPerTimeRange: draft.attemptsPerTimeRange, attemptsTimeRangeHours: draft.attemptsTimeRangeHours, @@ -445,7 +467,7 @@ export class DraftManagementService { async getLatestDraft( assignmentId: number, - userSession: UserSession, + userSession: UserSession ): Promise<{ id: number; name: string; @@ -456,6 +478,8 @@ export class DraftManagementService { type: string; graded: boolean; numAttempts: number; + attemptsBeforeCoolDown: number; + retakeAttemptCoolDownMinutes: number; allotedTimeMinutes: number; attemptsPerTimeRange: number; attemptsTimeRangeHours: number; @@ -496,7 +520,7 @@ export class DraftManagementService { private async verifyAssignmentAccess( assignmentId: number, - userSession: UserSession, + userSession: UserSession ) { const assignment = await this.prisma.assignment.findUnique({ where: { id: assignmentId }, @@ -509,7 +533,7 @@ export class DraftManagementService { if (userSession.role === UserRole.AUTHOR) { const hasAccess = assignment.AssignmentAuthor.some( - (author) => author.userId === userSession.userId, + (author) => author.userId === userSession.userId ); if (!hasAccess) { throw new NotFoundException("Assignment not found"); 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 b159e94a..08864185 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 @@ -24,6 +24,7 @@ import { import { Logger } from "winston"; import { PrismaService } from "../../../../prisma.service"; import { QuestionDto } from "../../dto/update.questions.request.dto"; +import { assign } from "nodemailer/lib/shared"; export interface CreateVersionDto { versionNumber?: string; @@ -302,6 +303,8 @@ export class VersionManagementService { type: assignment.type, graded: assignment.graded, numAttempts: assignment.numAttempts, + attemptsBeforeCoolDown: assignment.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: assignment.retakeAttemptCoolDownMinutes, allotedTimeMinutes: assignment.allotedTimeMinutes, attemptsPerTimeRange: assignment.attemptsPerTimeRange, attemptsTimeRangeHours: assignment.attemptsTimeRangeHours, @@ -555,6 +558,8 @@ export class VersionManagementService { type: version.type, graded: version.graded, numAttempts: version.numAttempts, + attemptsBeforeCoolDown: version.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: version.retakeAttemptCoolDownMinutes, allotedTimeMinutes: version.allotedTimeMinutes, attemptsPerTimeRange: version.attemptsPerTimeRange, attemptsTimeRangeHours: version.attemptsTimeRangeHours, @@ -639,6 +644,8 @@ export class VersionManagementService { type: versionToRestore.type, graded: versionToRestore.graded, numAttempts: versionToRestore.numAttempts, + attemptsBeforeCoolDown: versionToRestore.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: versionToRestore.retakeAttemptCoolDownMinutes, allotedTimeMinutes: versionToRestore.allotedTimeMinutes, attemptsPerTimeRange: versionToRestore.attemptsPerTimeRange, attemptsTimeRangeHours: versionToRestore.attemptsTimeRangeHours, @@ -1088,6 +1095,8 @@ export class VersionManagementService { type: assignment.type, graded: assignment.graded, numAttempts: assignment.numAttempts, + attemptsBeforeCoolDown: assignment.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: assignment.retakeAttemptCoolDownMinutes, allotedTimeMinutes: assignment.allotedTimeMinutes, attemptsPerTimeRange: assignment.attemptsPerTimeRange, attemptsTimeRangeHours: assignment.attemptsTimeRangeHours, @@ -1395,6 +1404,8 @@ export class VersionManagementService { type: assignment.type, graded: assignment.graded, numAttempts: assignment.numAttempts, + attemptsBeforeCoolDown: assignment.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: assignment.retakeAttemptCoolDownMinutes, allotedTimeMinutes: assignment.allotedTimeMinutes, attemptsPerTimeRange: assignment.attemptsPerTimeRange, attemptsTimeRangeHours: assignment.attemptsTimeRangeHours, @@ -1489,6 +1500,8 @@ export class VersionManagementService { type: string; graded: boolean; numAttempts: number | null; + attemptsBeforeCoolDown: number | null; + retakeAttemptCoolDownMinutes: number | null; allotedTimeMinutes: number | null; passingGrade: number | null; displayOrder: string | null; @@ -1531,6 +1544,8 @@ export class VersionManagementService { type: latestDraft.type, graded: latestDraft.graded, numAttempts: latestDraft.numAttempts, + attemptsBeforeCoolDown: latestDraft.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: latestDraft.retakeAttemptCoolDownMinutes, allotedTimeMinutes: latestDraft.allotedTimeMinutes, passingGrade: latestDraft.passingGrade, displayOrder: latestDraft.displayOrder, @@ -1627,6 +1642,8 @@ export class VersionManagementService { type: sourceVersion.type, graded: sourceVersion.graded, numAttempts: sourceVersion.numAttempts, + attemptsBeforeCoolDown: sourceVersion.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: sourceVersion.retakeAttemptCoolDownMinutes, allotedTimeMinutes: sourceVersion.allotedTimeMinutes, attemptsPerTimeRange: sourceVersion.attemptsPerTimeRange, attemptsTimeRangeHours: sourceVersion.attemptsTimeRangeHours, @@ -2087,6 +2104,10 @@ export class VersionManagementService { graded: draftData.assignmentData.graded ?? assignment.graded, numAttempts: draftData.assignmentData.numAttempts ?? assignment.numAttempts, + attemptsBeforeCoolDown: + draftData.assignmentData.attemptsBeforeCoolDown ?? assignment.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: + draftData.assignmentData.retakeAttemptCoolDownMinutes ?? assignment.retakeAttemptCoolDownMinutes, allotedTimeMinutes: draftData.assignmentData.allotedTimeMinutes ?? assignment.allotedTimeMinutes, 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 7698adb1..024e1293 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 @@ -951,6 +951,8 @@ export const createMockAssignment = ( type: AssignmentType.AI_GRADED, graded: false, numAttempts: -1, + attemptsBeforeCoolDown: 1, + retakeAttemptCoolDownMinutes: 5, allotedTimeMinutes: null, attemptsPerTimeRange: null, attemptsTimeRangeHours: null, @@ -1049,6 +1051,8 @@ export const createMockUpdateAssignmentDto = ( timeEstimateMinutes: 45, graded: true, numAttempts: 3, + attemptsBeforeCoolDown: 1, + retakeAttemptCoolDownMinutes: 5, allotedTimeMinutes: 60, attemptsPerTimeRange: null, attemptsTimeRangeHours: null, @@ -1078,6 +1082,8 @@ export const createMockReplaceAssignmentDto = ( timeEstimateMinutes: 60, graded: true, numAttempts: 2, + attemptsBeforeCoolDown: 1, + retakeAttemptCoolDownMinutes: 5, allotedTimeMinutes: 90, attemptsPerTimeRange: null, attemptsTimeRangeHours: null, @@ -1103,6 +1109,8 @@ export const createMockUpdateAssignmentQuestionsDto = ( timeEstimateMinutes: 45, graded: true, numAttempts: 3, + attemptsBeforeCoolDown: 1, + retakeAttemptCoolDownMinutes: 5, allotedTimeMinutes: 60, attemptsPerTimeRange: null, attemptsTimeRangeHours: null, @@ -1630,6 +1638,8 @@ export const createMockAssignmentRepository = () => ({ createEmptyDto: jest.fn().mockReturnValue({ instructions: undefined, numAttempts: undefined, + attemptsBeforeCoolDown: undefined, + retakeAttemptCoolDownMinutes: undefined, allotedTimeMinutes: undefined, attemptsPerTimeRange: undefined, attemptsTimeRangeHours: undefined, diff --git a/apps/api/src/api/assignment/v2/tests/unit/repositories/assignment.repository.spec.ts b/apps/api/src/api/assignment/v2/tests/unit/repositories/assignment.repository.spec.ts index d9da036b..dd8ca3a8 100644 --- a/apps/api/src/api/assignment/v2/tests/unit/repositories/assignment.repository.spec.ts +++ b/apps/api/src/api/assignment/v2/tests/unit/repositories/assignment.repository.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable unicorn/no-null */ /* eslint-disable @typescript-eslint/unbound-method */ @@ -107,7 +108,15 @@ describe("AssignmentRepository", () => { expect(prismaService.assignment.findUnique).toHaveBeenCalledWith({ where: { id: assignmentId }, include: { + currentVersion: { include: { questionVersions: true } }, + versions: { + where: { isActive: true }, + include: { questionVersions: true }, + orderBy: { id: "desc" }, + take: 1, + }, questions: { + where: { isDeleted: false }, include: { variants: true }, }, }, @@ -174,7 +183,15 @@ describe("AssignmentRepository", () => { expect(prismaService.assignment.findUnique).toHaveBeenCalledWith({ where: { id: assignmentId }, include: { + currentVersion: { include: { questionVersions: true } }, + versions: { + where: { isActive: true }, + include: { questionVersions: true }, + orderBy: { id: "desc" }, + take: 1, + }, questions: { + where: { isDeleted: false }, include: { variants: true }, }, }, @@ -318,6 +335,551 @@ describe("AssignmentRepository", () => { global.JSON.parse = originalJsonParse; }); + + describe("version handling", () => { + it("should use currentVersion when it exists and is active", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ + id: assignmentId, + name: "Original Assignment", + }), + questions: [], + currentVersion: { + id: 10, + isActive: true, + name: "Current Version Assignment", + introduction: "Current version intro", + questionVersions: [ + { + id: 100, + questionId: null, + type: QuestionType.SINGLE_CORRECT, + question: "Version question 1", + totalPoints: 15, + displayOrder: 1, + responseType: null, + maxWords: null, + scoring: null, + choices: null, + randomizedChoices: null, + answer: null, + gradingContextQuestionIds: [], + maxCharacters: null, + videoPresentationConfig: null, + liveRecordingConfig: null, + }, + ], + }, + versions: [ + { + id: 11, + isActive: true, + name: "Other Active Version", + questionVersions: [], + }, + ], + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + expect(result.name).toBe("Current Version Assignment"); + expect(result.introduction).toBe("Current version intro"); + expect(result.questions).toBeDefined(); + expect(result.questions.length).toBe(1); + expect(result.questions[0].question).toBe("Version question 1"); + expect(result.questions[0].totalPoints).toBe(15); + expect(result.questions[0].id).toBe(-100); // Negative ID for version questions + }); + + it("should use versions[0] when currentVersion is not active", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ + id: assignmentId, + name: "Original Assignment", + }), + questions: [], + currentVersion: { + id: 10, + isActive: false, + name: "Inactive Current Version", + questionVersions: [], + }, + versions: [ + { + id: 11, + isActive: true, + name: "Active Version From Array", + introduction: "Active version intro", + questionVersions: [ + { + id: 200, + questionId: 5, + type: QuestionType.MULTIPLE_CORRECT, + question: "Active version question", + totalPoints: 20, + displayOrder: 2, + responseType: "CHECKBOX", + maxWords: 100, + scoring: JSON.stringify({ type: "MANUAL" }), + choices: JSON.stringify([{ id: 1, choice: "Choice A" }]), + randomizedChoices: true, + answer: "Answer here", + gradingContextQuestionIds: [1, 2], + maxCharacters: 500, + videoPresentationConfig: JSON.stringify({ duration: 60 }), + liveRecordingConfig: JSON.stringify({ maxDuration: 120 }), + }, + ], + }, + ], + }; + + // Mock questions in the assignment + mockAssignment.questions = [ + { + id: 5, + question: "Legacy question", + type: QuestionType.MULTIPLE_CORRECT, + isDeleted: false, + variants: [ + { + id: 501, + questionId: 5, + variantContent: "Legacy variant", + isDeleted: false, + choices: JSON.stringify([]), + }, + ], + assignmentId: 1, + }, + ]; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + expect(result.name).toBe("Active Version From Array"); + expect(result.introduction).toBe("Active version intro"); + expect(result.questions).toBeDefined(); + expect(result.questions.length).toBe(1); + expect(result.questions[0].question).toBe("Active version question"); + expect(result.questions[0].totalPoints).toBe(20); + expect(result.questions[0].id).toBe(5); // Uses questionId when available + expect((result.questions[0] as any).variants).toBeDefined(); + expect((result.questions[0] as any).variants.length).toBe(1); + }); + + it("should fall back to original assignment when no active version exists", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ + id: assignmentId, + name: "Original Assignment", + }), + questions: [ + { + id: 1, + question: "Original question", + type: QuestionType.SINGLE_CORRECT, + isDeleted: false, + variants: [], + assignmentId: 1, + }, + ], + currentVersion: null, + versions: [], + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + expect(result.name).toBe("Original Assignment"); + expect(result.questions).toBeDefined(); + expect(result.questions.length).toBe(1); + expect(result.questions[0].question).toBe("Original question"); + }); + + it("should handle questionVersions sorting by displayOrder and id", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ id: assignmentId }), + questions: [], + currentVersion: { + id: 10, + isActive: true, + questionVersions: [ + { + id: 300, + questionId: null, + type: QuestionType.SINGLE_CORRECT, + question: "Question with display order 3", + totalPoints: 10, + displayOrder: 3, + responseType: null, + maxWords: null, + scoring: null, + choices: null, + randomizedChoices: null, + answer: null, + gradingContextQuestionIds: [], + maxCharacters: null, + videoPresentationConfig: null, + liveRecordingConfig: null, + }, + { + id: 100, + questionId: null, + type: QuestionType.SINGLE_CORRECT, + question: "Question with display order 1", + totalPoints: 10, + displayOrder: 1, + responseType: null, + maxWords: null, + scoring: null, + choices: null, + randomizedChoices: null, + answer: null, + gradingContextQuestionIds: [], + maxCharacters: null, + videoPresentationConfig: null, + liveRecordingConfig: null, + }, + { + id: 200, + questionId: null, + type: QuestionType.SINGLE_CORRECT, + question: "Question with display order 1 but higher ID", + totalPoints: 10, + displayOrder: 1, + responseType: null, + maxWords: null, + scoring: null, + choices: null, + randomizedChoices: null, + answer: null, + gradingContextQuestionIds: [], + maxCharacters: null, + videoPresentationConfig: null, + liveRecordingConfig: null, + }, + ], + }, + versions: [], + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + expect(result.questions).toBeDefined(); + expect(result.questions.length).toBe(3); + // Should be sorted by displayOrder first, then by id + expect(result.questions[0].question).toBe( + "Question with display order 1", + ); + expect(result.questions[1].question).toBe( + "Question with display order 1 but higher ID", + ); + expect(result.questions[2].question).toBe( + "Question with display order 3", + ); + }); + + it("should use activeVersion questionOrder over assignment questionOrder", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ id: assignmentId }), + questions: [], + questionOrder: [3, 2, 1], // Original order + currentVersion: { + id: 10, + isActive: true, + questionOrder: [1, 2, 3], // Version order should take precedence + questionVersions: [], + }, + versions: [], + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + expect(result.questionOrder).toEqual([1, 2, 3]); + }); + + it("should fall back to assignment questionOrder when version has empty questionOrder", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ id: assignmentId }), + questions: [], + questionOrder: [3, 2, 1], + currentVersion: { + id: 10, + isActive: true, + questionOrder: [], // Empty version order + questionVersions: [], + }, + versions: [], + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + expect(result.questionOrder).toEqual([3, 2, 1]); + }); + }); + }); + + describe("edge cases and error scenarios", () => { + it("should handle database connection errors gracefully", async () => { + const assignmentId = 1; + const databaseError = new Error("Database connection failed"); + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockRejectedValue(databaseError); + + await expect(repository.findById(assignmentId)).rejects.toThrow( + databaseError, + ); + }); + + it("should handle assignments with null questionVersions", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ id: assignmentId }), + questions: [], + currentVersion: { + id: 10, + isActive: true, + questionVersions: null, // null instead of empty array + }, + versions: [], + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + expect(result.questions).toBeDefined(); + expect(result.questions.length).toBe(0); + }); + + it("should handle questionVersions with null displayOrder", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ id: assignmentId }), + questions: [], + currentVersion: { + id: 10, + isActive: true, + questionVersions: [ + { + id: 100, + questionId: null, + type: QuestionType.SINGLE_CORRECT, + question: "Question with null displayOrder", + totalPoints: 10, + displayOrder: null, // null displayOrder + responseType: null, + maxWords: null, + scoring: null, + choices: null, + randomizedChoices: null, + answer: null, + gradingContextQuestionIds: [], + maxCharacters: null, + videoPresentationConfig: null, + liveRecordingConfig: null, + }, + { + id: 200, + questionId: null, + type: QuestionType.SINGLE_CORRECT, + question: "Question with undefined displayOrder", + totalPoints: 10, + displayOrder: undefined, // undefined displayOrder + responseType: null, + maxWords: null, + scoring: null, + choices: null, + randomizedChoices: null, + answer: null, + gradingContextQuestionIds: [], + maxCharacters: null, + videoPresentationConfig: null, + liveRecordingConfig: null, + }, + ], + }, + versions: [], + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + expect(result.questions).toBeDefined(); + expect(result.questions.length).toBe(2); + // Should sort by ID when displayOrder is null/undefined + expect(result.questions[0].id).toBe(-100); + expect(result.questions[1].id).toBe(-200); + }); + + it("should handle assignments without userSession", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ id: assignmentId }), + questions: [], + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const result = await repository.findById(assignmentId); + + expect(result).toBeDefined(); + expect(result.success).toBe(true); + // Should default to author view when no userSession provided + expect((result as GetAssignmentResponseDto).questions).toBeDefined(); + }); + + it("should handle empty or undefined question arrays in processAssignmentData", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ id: assignmentId }), + questions: undefined, // undefined questions + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + expect(result.questions).toBeDefined(); + expect(result.questions).toEqual([]); + }); + + it("should handle assignments with circular references in JSON fields", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ id: assignmentId }), + questions: [ + { + id: 1, + question: "Question with invalid JSON scoring", + type: QuestionType.SINGLE_CORRECT, + isDeleted: false, + scoring: '{"type":"AUTO", invalid json', // Invalid JSON + choices: "not json at all", // Invalid JSON + variants: [], + assignmentId: 1, + }, + ], + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + jest.spyOn(repository["logger"], "error").mockImplementation(jest.fn()); + + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + expect(result.questions).toBeDefined(); + expect(result.questions.length).toBe(1); + expect(result.questions[0].scoring).toBeUndefined(); + expect(result.questions[0].choices).toBeUndefined(); + expect(repository["logger"].error).toHaveBeenCalled(); + }); + + it("should handle very large datasets efficiently", async () => { + const assignmentId = 1; + const largeQuestionSet = Array.from({ length: 1000 }, (_, index) => ({ + id: index + 1, + question: `Question ${index + 1}`, + type: QuestionType.SINGLE_CORRECT, + isDeleted: false, + scoring: JSON.stringify({ type: "AUTO" }), + choices: JSON.stringify([{ id: 1, choice: "Answer", isCorrect: true }]), + variants: [], + assignmentId: 1, + })); + + const mockAssignment = { + ...createMockAssignment({ id: assignmentId }), + questions: largeQuestionSet, + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const start = Date.now(); + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + const end = Date.now(); + + expect(result.questions).toBeDefined(); + expect(result.questions.length).toBe(1000); + expect(end - start).toBeLessThan(5000); // Should complete within 5 seconds + }); }); describe("findAllForUser", () => { @@ -364,6 +926,58 @@ describe("AssignmentRepository", () => { expect(result).toBeDefined(); expect(result).toEqual([]); }); + + it("should handle authors by finding authored assignments", async () => { + const mockAuthorAssignments = [ + createMockAssignment({ id: 1, name: "Authored Assignment 1" }), + createMockAssignment({ id: 2, name: "Authored Assignment 2" }), + ]; + + // Mock the assignment.findMany for authors + const mockAssignmentFindMany = jest + .fn() + .mockResolvedValue(mockAuthorAssignments); + (prismaService.assignment as any).findMany = mockAssignmentFindMany; + + const result = await repository.findAllForUser(sampleAuthorSession); + + expect(result).toBeDefined(); + expect(result.length).toBe(2); + expect(result[0].name).toBe("Authored Assignment 1"); + expect(result[1].name).toBe("Authored Assignment 2"); + + expect(mockAssignmentFindMany).toHaveBeenCalledWith({ + where: { + AssignmentAuthor: { + some: { + userId: sampleAuthorSession.userId, + }, + }, + }, + }); + }); + + it("should handle database errors in findAllForUser", async () => { + const databaseError = new Error("Database connection failed"); + + jest + .spyOn(prismaService.assignmentGroup, "findMany") + .mockRejectedValue(databaseError); + + await expect( + repository.findAllForUser(sampleLearnerSession), + ).rejects.toThrow(databaseError); + }); + + it("should handle null assignment groups result", async () => { + jest + .spyOn(prismaService.assignmentGroup, "findMany") + .mockResolvedValue(null); + + const result = await repository.findAllForUser(sampleLearnerSession); + + expect(result).toEqual([]); + }); }); describe("update", () => { @@ -438,6 +1052,8 @@ describe("AssignmentRepository", () => { jest.spyOn(repository as any, "createEmptyDto").mockReturnValue({ instructions: undefined, numAttempts: undefined, + attemptsBeforeCoolDown: undefined, + retakeAttemptCoolDownMinutes: undefined, allotedTimeMinutes: undefined, attemptsPerTimeRange: undefined, attemptsTimeRangeHours: undefined, @@ -623,4 +1239,229 @@ describe("AssignmentRepository", () => { global.JSON.parse = originalJsonParse; }); }); + + describe("field merging logic", () => { + it("should merge fields correctly with priority: primary > secondary > defaults", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ + id: assignmentId, + name: "Original Assignment", + introduction: "Original intro", + attemptsBeforeCoolDown: 3, // From assignment + }), + questions: [], + currentVersion: { + id: 10, + isActive: true, + name: "Version Assignment", // Should override + // introduction is undefined in version, should fall back to assignment + attemptsBeforeCoolDown: 5, // Should override assignment + retakeAttemptCoolDownMinutes: 10, // Should override default + questionVersions: [], + }, + versions: [], + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + expect(result.name).toBe("Version Assignment"); // From version (primary) + expect(result.introduction).toBe("Original intro"); // From assignment (secondary) + expect(result.attemptsBeforeCoolDown).toBe(5); // From version (primary) + expect(result.retakeAttemptCoolDownMinutes).toBe(10); // From version (primary) + expect(result.passingGrade).toBe(50); // From defaults + expect(result.graded).toBe(false); // From defaults + }); + + it("should handle null and undefined values in field merging correctly", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ + id: assignmentId, + name: null, // null in assignment + introduction: undefined, // undefined in assignment + }), + questions: [], + currentVersion: { + id: 10, + isActive: true, + name: undefined, // undefined in version + introduction: null, // null in version + graded: null, // null should not override default + questionVersions: [], + }, + versions: [], + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + // When both primary and secondary are null/undefined, should use defaults + expect(result.name).toBeNull(); // prefer() returns null when all are null/undefined + expect(result.introduction).toBeNull(); + expect(result.graded).toBe(false); // Should use default since version has null + }); + + it("should handle all field types in FIELDS constant", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ id: assignmentId }), + questions: [], + currentVersion: { + id: 10, + isActive: true, + // Test various field types + name: "Version Name", + introduction: "Version Intro", + instructions: "Version Instructions", + gradingCriteriaOverview: "Version Grading", + timeEstimateMinutes: 45, + attemptsBeforeCoolDown: 2, + retakeAttemptCoolDownMinutes: 15, + type: "ASSIGNMENT", + graded: true, + numAttempts: 3, + allotedTimeMinutes: 60, + attemptsPerTimeRange: 5, + attemptsTimeRangeHours: 24, + passingGrade: 75, + displayOrder: "RANDOM", + questionDisplay: "ALL_AT_ONCE", + numberOfQuestionsPerAttempt: 10, + published: true, + showAssignmentScore: false, + showQuestionScore: false, + showSubmissionFeedback: false, + showQuestions: false, + languageCode: "es-ES", + questionVersions: [], + }, + versions: [], + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + // Verify all fields are properly merged from version + expect(result.name).toBe("Version Name"); + expect(result.introduction).toBe("Version Intro"); + expect(result.instructions).toBe("Version Instructions"); + expect(result.gradingCriteriaOverview).toBe("Version Grading"); + expect(result.timeEstimateMinutes).toBe(45); + expect(result.attemptsBeforeCoolDown).toBe(2); + expect(result.retakeAttemptCoolDownMinutes).toBe(15); + expect(result.graded).toBe(true); + expect(result.numAttempts).toBe(3); + expect(result.allotedTimeMinutes).toBe(60); + expect(result.attemptsPerTimeRange).toBe(5); + expect(result.attemptsTimeRangeHours).toBe(24); + expect(result.passingGrade).toBe(75); + expect(result.questionDisplay).toBe("ALL_AT_ONCE"); + expect(result.numberOfQuestionsPerAttempt).toBe(10); + expect(result.published).toBe(true); + expect(result.showAssignmentScore).toBe(false); + expect(result.showQuestionScore).toBe(false); + expect(result.showSubmissionFeedback).toBe(false); + expect(result.showQuestions).toBe(false); + expect((result as any).languageCode).toBe("es-ES"); + }); + }); + + describe("integration tests", () => { + it("should handle complex merging scenarios in real usage", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ + id: assignmentId, + name: "Assignment Name", + introduction: null, // null in assignment + attemptsBeforeCoolDown: undefined, // undefined in assignment + }), + questions: [], + currentVersion: { + id: 10, + isActive: true, + name: null, // null in version, should fall back to assignment + introduction: "Version Introduction", // should override null assignment value + attemptsBeforeCoolDown: 3, // should override undefined assignment value + retakeAttemptCoolDownMinutes: undefined, // undefined in version, should use default + questionVersions: [], + }, + versions: [], + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + const result = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + // This test verifies the merging logic works correctly in practice + expect(result.name).toBe("Assignment Name"); // Falls back to assignment when version is null + expect(result.introduction).toBe("Version Introduction"); // Version overrides null assignment + expect(result.attemptsBeforeCoolDown).toBe(3); // Version overrides undefined assignment + expect(result.retakeAttemptCoolDownMinutes).toBe(5); // Uses default when both are null/undefined + }); + + it("should maintain data integrity across different user roles", async () => { + const assignmentId = 1; + const mockAssignment = { + ...createMockAssignment({ id: assignmentId }), + questions: [ + { + id: 1, + question: "Test question", + type: QuestionType.SINGLE_CORRECT, + isDeleted: false, + variants: [], + assignmentId: 1, + }, + ], + }; + + jest + .spyOn(prismaService.assignment, "findUnique") + .mockResolvedValue(mockAssignment); + + // Test author view + const authorResult = (await repository.findById( + assignmentId, + sampleAuthorSession, + )) as GetAssignmentResponseDto; + + // Test learner view + const learnerResult = await repository.findById( + assignmentId, + sampleLearnerSession, + ); + + expect(authorResult.questions).toBeDefined(); + expect(authorResult.questions.length).toBe(1); + expect(learnerResult.questions).toBeUndefined(); // Learners shouldn't see questions + expect(authorResult.success).toBe(true); + expect(learnerResult.success).toBe(true); + }); + }); }); diff --git a/apps/api/src/api/assignment/v2/tests/unit/services/assignment.service.spec.ts b/apps/api/src/api/assignment/v2/tests/unit/services/assignment.service.spec.ts index 5d0a367c..f8d67d66 100644 --- a/apps/api/src/api/assignment/v2/tests/unit/services/assignment.service.spec.ts +++ b/apps/api/src/api/assignment/v2/tests/unit/services/assignment.service.spec.ts @@ -356,6 +356,8 @@ describe("AssignmentServiceV2 – full unit-suite", () => { timeEstimateMinutes: 0, attemptsPerTimeRange: 0, attemptsTimeRangeHours: 0, + retakeAttemptCoolDownMinutes: 5, + attemptsBeforeCoolDown: 1, displayOrder: "DEFINED", questionDisplay: "ONE_PER_PAGE", questionOrder: [], diff --git a/apps/api/src/api/attempt/attempt.controller.ts b/apps/api/src/api/attempt/attempt.controller.ts index 3a06dcf8..570c18ad 100644 --- a/apps/api/src/api/attempt/attempt.controller.ts +++ b/apps/api/src/api/attempt/attempt.controller.ts @@ -34,6 +34,7 @@ import { GRADE_SUBMISSION_EXCEPTION, MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, SUBMISSION_DEADLINE_EXCEPTION_MESSAGE, + IN_COOLDOWN_PERIOD, } from "../assignment/attempt/api-exceptions/exceptions"; import { BaseAssignmentAttemptResponseDto } from "../assignment/attempt/dto/assignment-attempt/base.assignment.attempt.response.dto"; import { LearnerUpdateAssignmentAttemptRequestDto } from "../assignment/attempt/dto/assignment-attempt/create.update.assignment.attempt.request.dto"; @@ -83,6 +84,11 @@ export class AttemptControllerV2 { type: String, description: MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, }) + @ApiResponse({ + status: 429, + type: String, + description: IN_COOLDOWN_PERIOD, + }) @ApiResponse({ status: 403 }) createAssignmentAttempt( @Param("assignmentId") assignmentId: number, @@ -105,6 +111,11 @@ export class AttemptControllerV2 { type: String, description: MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, }) + @ApiResponse({ + status: 429, + type: String, + description: IN_COOLDOWN_PERIOD, + }) @ApiResponse({ status: 500, type: String, @@ -178,6 +189,11 @@ export class AttemptControllerV2 { type: String, description: SUBMISSION_DEADLINE_EXCEPTION_MESSAGE, }) + @ApiResponse({ + status: 429, + type: String, + description: IN_COOLDOWN_PERIOD, + }) @ApiResponse({ status: 500, type: String, diff --git a/apps/api/src/api/attempt/services/attempt-validation.service.ts b/apps/api/src/api/attempt/services/attempt-validation.service.ts index 81178e96..db206e81 100644 --- a/apps/api/src/api/attempt/services/attempt-validation.service.ts +++ b/apps/api/src/api/attempt/services/attempt-validation.service.ts @@ -1,5 +1,11 @@ -import { Injectable, UnprocessableEntityException } from "@nestjs/common"; import { + HttpException, + HttpStatus, + Injectable, + UnprocessableEntityException, +} from "@nestjs/common"; +import { + IN_COOLDOWN_PERIOD, IN_PROGRESS_SUBMISSION_EXCEPTION, MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, SUBMISSION_DEADLINE_EXCEPTION_MESSAGE, @@ -25,6 +31,7 @@ export class AttemptValidationService { assignment: GetAssignmentResponseDto | LearnerGetAssignmentResponseDto, userSession: UserSession, ): Promise { + const now = new Date(); const timeRangeStartDate = this.calculateTimeRangeStartDate(assignment); const attempts = await this.prisma.assignmentAttempt.findMany({ @@ -35,7 +42,7 @@ export class AttemptValidationService { { submitted: false, expiresAt: { - gte: new Date(), + gte: now, }, }, { @@ -45,17 +52,25 @@ export class AttemptValidationService { { createdAt: { gte: timeRangeStartDate, - lte: new Date(), + lte: now, }, }, ], }, + orderBy: { createdAt: "desc" }, + }); + + const lastSubmittedAttempt = await this.prisma.assignmentAttempt.findFirst({ + where: { + userId: userSession.userId, + assignmentId: assignment.id, + submitted: true, + }, + orderBy: { expiresAt: "desc" }, }); const ongoingAttempts = attempts.filter( - (sub) => - !sub.submitted && - (sub.expiresAt >= new Date() || sub.expiresAt === null), + (sub) => !sub.submitted && (!sub.expiresAt || sub.expiresAt >= now), ); if (ongoingAttempts.length > 0) { @@ -63,8 +78,7 @@ export class AttemptValidationService { } const attemptsInTimeRange = attempts.filter( - (sub) => - sub.createdAt >= timeRangeStartDate && sub.createdAt <= new Date(), + (sub) => sub.createdAt >= timeRangeStartDate && sub.createdAt <= now, ); if ( @@ -77,16 +91,40 @@ export class AttemptValidationService { } if (assignment.numAttempts !== null && assignment.numAttempts !== -1) { - const attemptCount = await this.countUserAttempts( + const totalAttempts = await this.countUserAttempts( userSession.userId, assignment.id, ); - if (attemptCount >= assignment.numAttempts) { + if (totalAttempts >= assignment.numAttempts) { throw new UnprocessableEntityException( MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, ); } + + const attemptsBeforeCoolDown = assignment.attemptsBeforeCoolDown ?? 1; + const cooldownMinutes = assignment.retakeAttemptCoolDownMinutes ?? 0; + + if ( + attemptsBeforeCoolDown > 0 && + totalAttempts >= attemptsBeforeCoolDown + ) { + const lastAttemptTime = new Date( + lastSubmittedAttempt.expiresAt, + ).getTime(); + const cooldownMs = cooldownMinutes * 60_000; + const nextEligibleTime = lastAttemptTime + cooldownMs; + + if (now.getTime() < nextEligibleTime) { + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: IN_COOLDOWN_PERIOD, + }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } } } diff --git a/apps/api/src/assets/schema/assignment-example.json b/apps/api/src/assets/schema/assignment-example.json index 2f249150..40efe17d 100644 --- a/apps/api/src/assets/schema/assignment-example.json +++ b/apps/api/src/assets/schema/assignment-example.json @@ -5,6 +5,8 @@ "type": "AI_GRADED", "graded": true, "numAttempts": 5, + "attemptsBeforeCoolDown": 1, + "retakeAttemptCoolDownMinutes": 5, "allotedTimeMinutes": 60, "attemptsPerTimeRange": 5, "attemptsTimeRangeHours": 2, diff --git a/apps/api/src/assets/schema/assignment-schema.json b/apps/api/src/assets/schema/assignment-schema.json index c8e6e5ac..4e6c72db 100644 --- a/apps/api/src/assets/schema/assignment-schema.json +++ b/apps/api/src/assets/schema/assignment-schema.json @@ -28,6 +28,12 @@ "type": "string", "enum": ["DEFINED", "RANDOM"] }, + "attemptsBeforeCoolDown": { + "type": "integer" + }, + "retakeAttemptCoolDownMinutes": { + "type": "integer" + }, "questions": { "type": "array", "items": { @@ -79,6 +85,8 @@ "name", "type", "numAttempts", + "attemptsBeforeCoolDown", + "retakeAttemptCoolDownMinutes", "allotedTimeMinutes", "passingGrade", "displayOrder", diff --git a/apps/api/src/scripts/create-initial-versions.ts b/apps/api/src/scripts/create-initial-versions.ts index 833eaa5e..2cb9362e 100644 --- a/apps/api/src/scripts/create-initial-versions.ts +++ b/apps/api/src/scripts/create-initial-versions.ts @@ -61,6 +61,8 @@ async function createInitialVersions() { type: assignment.type, graded: assignment.graded, numAttempts: assignment.numAttempts, + attemptsBeforeCoolDown: assignment.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: assignment.retakeAttemptCoolDownMinutes, allotedTimeMinutes: assignment.allotedTimeMinutes, attemptsPerTimeRange: assignment.attemptsPerTimeRange, attemptsTimeRangeHours: assignment.attemptsTimeRangeHours, diff --git a/apps/web/app/Helpers/checkDiff.ts b/apps/web/app/Helpers/checkDiff.ts index e5498a8d..9ab3b7ac 100644 --- a/apps/web/app/Helpers/checkDiff.ts +++ b/apps/web/app/Helpers/checkDiff.ts @@ -58,6 +58,8 @@ export function useChangesSummary(): string { questionDisplay, questionVariationNumber, numAttempts, + attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, passingGrade, timeEstimateMinutes, allotedTimeMinutes, @@ -129,7 +131,7 @@ export function useChangesSummary(): string { const originalQuestions = originalAssignment.questions || []; const currentQuestions = questions || []; - const addedQuestions = currentQuestions.filter( + const addedQuestions = currentQuestions?.filter( (question) => !originalQuestions.some((origQ) => origQ.id === question?.id), ); @@ -383,6 +385,24 @@ export function useChangesSummary(): string { diffs.push("Updated number of attempts."); } + if ( + !safeCompare( + attemptsBeforeCoolDown, + originalAssignment.attemptsBeforeCoolDown, + ) + ) { + diffs.push("Updated number of attempts before cooldown period."); + } + + if ( + !safeCompare( + retakeAttemptCoolDownMinutes, + originalAssignment.retakeAttemptCoolDownMinutes, + ) + ) { + diffs.push("Updated the cooldown time before retries allowed."); + } + if (!safeCompare(passingGrade, originalAssignment.passingGrade)) { diffs.push("Modified passing grade."); } @@ -418,6 +438,8 @@ export function useChangesSummary(): string { questionDisplay, questionVariationNumber, numAttempts, + attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, passingGrade, timeEstimateMinutes, allotedTimeMinutes, diff --git a/apps/web/app/Helpers/checkQuestionsReady.ts b/apps/web/app/Helpers/checkQuestionsReady.ts index 435e67f9..5b7c11fa 100644 --- a/apps/web/app/Helpers/checkQuestionsReady.ts +++ b/apps/web/app/Helpers/checkQuestionsReady.ts @@ -299,6 +299,24 @@ export const useQuestionsAreReadyToBePublished = ( step = 1; isValid = false; } + if ( + assignmentConfig.attemptsBeforeCoolDown == null || + assignmentConfig.attemptsBeforeCoolDown < -1 + ) { + message = `Please enter a valid number of attempts before cooldown period.`; + debugLog(message); + step = 3; + isValid = false; + } + if ( + assignmentConfig.retakeAttemptCoolDownMinutes == null || + assignmentConfig.retakeAttemptCoolDownMinutes < -1 + ) { + message = `Please enter a valid number of minutes for the cooldown period.`; + debugLog(message); + step = 3; + isValid = false; + } if ( assignmentConfig.passingGrade == null || assignmentConfig.passingGrade <= 0 || diff --git a/apps/web/app/author/(components)/AuthorQuestionsPage/Question.tsx b/apps/web/app/author/(components)/AuthorQuestionsPage/Question.tsx index febcedcd..5cd4823d 100644 --- a/apps/web/app/author/(components)/AuthorQuestionsPage/Question.tsx +++ b/apps/web/app/author/(components)/AuthorQuestionsPage/Question.tsx @@ -930,8 +930,8 @@ const Question: FC = ({ diff --git a/apps/web/app/author/(components)/AuthorQuestionsPage/index.tsx b/apps/web/app/author/(components)/AuthorQuestionsPage/index.tsx index 292bd779..2b30f00e 100644 --- a/apps/web/app/author/(components)/AuthorQuestionsPage/index.tsx +++ b/apps/web/app/author/(components)/AuthorQuestionsPage/index.tsx @@ -1020,6 +1020,14 @@ const AuthorQuestionsPage: FC = ({ configStore.setNumAttempts(assignmentData.config.numAttempts); settingsUpdated = true; } + if (assignmentData.config.attemptsBeforeCoolDown !== undefined) { + configStore.setAttemptsBeforeCoolDown(assignmentData.config.attemptsBeforeCoolDown); + settingsUpdated = true; + } + if (assignmentData.config.retakeAttemptCoolDownMinutes !== undefined) { + configStore.setRetakeAttemptCoolDownMinutes(assignmentData.config.retakeAttemptCoolDownMinutes); + settingsUpdated = true; + } if (assignmentData.config.allotedTimeMinutes !== undefined) { configStore.setAllotedTimeMinutes( assignmentData.config.allotedTimeMinutes, diff --git a/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx b/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx index 99cdcf53..c1c2112d 100644 --- a/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx +++ b/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx @@ -36,6 +36,9 @@ const CheckLearnerSideButton: FC = (props) => { questionDisplay: assignmentConfigstate.questionDisplay, graded: assignmentConfigstate.graded, numAttempts: assignmentConfigstate.numAttempts, + retakeAttemptCoolDownMinutes: + assignmentConfigstate.retakeAttemptCoolDownMinutes, + attemptsBeforeCoolDown: assignmentConfigstate.attemptsBeforeCoolDown, passingGrade: assignmentConfigstate.passingGrade, allotedTimeMinutes: assignmentConfigstate.allotedTimeMinutes, timeEstimateMinutes: assignmentConfigstate.timeEstimateMinutes, diff --git a/apps/web/app/author/(components)/Header/index.tsx b/apps/web/app/author/(components)/Header/index.tsx index 633e1f87..41d02e95 100644 --- a/apps/web/app/author/(components)/Header/index.tsx +++ b/apps/web/app/author/(components)/Header/index.tsx @@ -138,6 +138,8 @@ function AuthorHeader() { ]); const [ numAttempts, + retakeAttemptCoolDownMinutes, + attemptsBeforeCoolDown, passingGrade, displayOrder, graded, @@ -148,6 +150,8 @@ function AuthorHeader() { numberOfQuestionsPerAttempt, ] = useAssignmentConfig((state) => [ state.numAttempts, + state.retakeAttemptCoolDownMinutes, + state.attemptsBeforeCoolDown, state.passingGrade, state.displayOrder, state.graded, @@ -258,6 +262,9 @@ function AuthorHeader() { useAssignmentConfig.getState().setAssignmentConfigStore({ numAttempts: newAssignment.numAttempts, + retakeAttemptCoolDownMinutes: + newAssignment.retakeAttemptCoolDownMinutes, + attemptsBeforeCoolDown: newAssignment.attemptsBeforeCoolDown, passingGrade: newAssignment.passingGrade, displayOrder: newAssignment.displayOrder, graded: newAssignment.graded, @@ -502,6 +509,8 @@ function AuthorHeader() { const assignmentData: ReplaceAssignmentRequest = { ...encodedFields, numAttempts, + retakeAttemptCoolDownMinutes, + attemptsBeforeCoolDown, passingGrade, displayOrder, graded, diff --git a/apps/web/app/author/(components)/StepTwo/AssignmentRetakeAttempts.tsx b/apps/web/app/author/(components)/StepTwo/AssignmentRetakeAttempts.tsx new file mode 100644 index 00000000..41ea9692 --- /dev/null +++ b/apps/web/app/author/(components)/StepTwo/AssignmentRetakeAttempts.tsx @@ -0,0 +1,110 @@ +"use client"; + +import Dropdown from "@/components/Dropdown"; +import Tooltip from "@/components/Tooltip"; +import { stepTwoSections } from "@/config/constants"; +import { useAssignmentConfig } from "@/stores/assignmentConfig"; +import { InformationCircleIcon } from "@heroicons/react/24/solid"; +import type { ComponentPropsWithoutRef, FC } from "react"; +import SectionWithTitle from "../ReusableSections/SectionWithTitle"; + +type Props = ComponentPropsWithoutRef<"div">; + +const Component: FC = () => { + const [ + attemptsBeforeCoolDown, + setAttemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, + setRetakeAttemptCoolDownMinutes, + errors, + ] = useAssignmentConfig((state) => [ + state.attemptsBeforeCoolDown, + state.setAttemptsBeforeCoolDown, + state.retakeAttemptCoolDownMinutes, + state.setRetakeAttemptCoolDownMinutes, + state.errors, + ]); + + const dropdownItems = [ + { value: 1, label: "Wait after every attempt" }, + { value: 2, label: "2" }, + { value: 3, label: "3" }, + { value: 4, label: "4" }, + { value: 5, label: "5" }, + { value: 10, label: "10" }, + { value: 0, label: "Never wait to retry" }, + ]; + const defaultCoolDownTimes = [ + { value: 5, label: "5 minutes" }, + { value: 10, label: "10 minutes" }, + { value: 60, label: "1 hour" }, + { value: 300, label: "5 hours" }, + { value: 1440, label: "1 day" }, + { value: 10080, label: "7 days" }, + { value: 0, label: "Never wait to retry" }, + ]; + + return ( + +
+ + + id="attempts-before-cooldown-period" + items={dropdownItems} + selectedItem={attemptsBeforeCoolDown} + setSelectedItem={setAttemptsBeforeCoolDown} + /> + {errors.attemptsBeforeCoolDown && ( +

+ {errors.attemptsBeforeCoolDown} +

+ )} +
+ {attemptsBeforeCoolDown > 0 && ( +
+ + + id="cooldown-period" + items={defaultCoolDownTimes} + selectedItem={retakeAttemptCoolDownMinutes} + setSelectedItem={setRetakeAttemptCoolDownMinutes} + /> + {errors.retakeAttemptCoolDownMinutes && ( +

+ {errors.retakeAttemptCoolDownMinutes} +

+ )} +
+ )} +
+ ); +}; + +export default Component; diff --git a/apps/web/app/author/[assignmentId]/config/page.tsx b/apps/web/app/author/[assignmentId]/config/page.tsx index 77a3ac1f..3aeeae47 100644 --- a/apps/web/app/author/[assignmentId]/config/page.tsx +++ b/apps/web/app/author/[assignmentId]/config/page.tsx @@ -2,6 +2,7 @@ import AssignmentQuestionOrder from "@/app/author/(components)/StepTwo/Assignmen import AssignmentType from "@/app/author/(components)/StepTwo/AssignmentType"; import PageTitle from "@authorComponents/PageTitle"; import AssignmentCompletion from "@authorComponents/StepTwo/AssignmentCompletion"; +import AssignmentRetakeAttempts from "@authorComponents/StepTwo/AssignmentRetakeAttempts"; import AssignmentFeedback from "@authorComponents/StepTwo/AssignmentFeedback"; import AssignmentTime from "@authorComponents/StepTwo/AssignmentTime"; import { FooterNavigation } from "@authorComponents/StepTwo/FooterNavigation"; @@ -23,6 +24,7 @@ function Component(props: Props) { + diff --git a/apps/web/app/author/[assignmentId]/review/page.tsx b/apps/web/app/author/[assignmentId]/review/page.tsx index 57cbacc5..f34c9e2d 100644 --- a/apps/web/app/author/[assignmentId]/review/page.tsx +++ b/apps/web/app/author/[assignmentId]/review/page.tsx @@ -749,6 +749,8 @@ function Component() { allotedTimeMinutes, timeEstimateMinutes, numAttempts, + attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, passingGrade, displayOrder, questionDisplay, @@ -759,6 +761,8 @@ function Component() { state.allotedTimeMinutes, state.timeEstimateMinutes, state.numAttempts, + state.attemptsBeforeCoolDown, + state.retakeAttemptCoolDownMinutes, state.passingGrade, state.displayOrder, state.questionDisplay, @@ -859,6 +863,12 @@ function Component() { numAttempts: filteredChanges.some((c) => c.includes("Updated number of attempts"), ), + attemptsBeforeCoolDown: filteredChanges.some((c) => + c.includes("Updated number of attempts before cooldown period"), + ), + retakeAttemptCoolDownMinutes: filteredChanges.some((c) => + c.includes("Updated the cooldown time before retries allowed"), + ), passingGrade: filteredChanges.some((c) => c.includes("Modified passing grade"), ), @@ -1119,6 +1129,8 @@ function Component() { allotedTimeMinutes, timeEstimateMinutes, numAttempts, + attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, passingGrade, displayOrder, questionDisplay, @@ -1648,6 +1660,8 @@ function Component() { changes.allotedTime || changes.timeEstimate || changes.numAttempts || + changes.attemptsBeforeCoolDown || + changes.retakeAttemptCoolDownMinutes || changes.passingGrade || changes.displayOrder || changes.questionDisplay || @@ -1735,6 +1749,22 @@ function Component() { /> )} + {changes.attemptsBeforeCoolDown && ( + + )} + + {changes.retakeAttemptCoolDownMinutes && ( + + )} + {changes.passingGrade && ( { - assignmentState: string; - assignmentId: number; - role?: string; - attemptsLeft: number; + disabled: boolean; + message: string; + label: string; + href: string; + className?: string; } const BeginTheAssignment: React.FC = (props) => { - const { assignmentState, assignmentId, className, role, attemptsLeft } = - props; + const { disabled, message, label, href, className } = props; const userPreferedLanguage = useLearnerStore( (state) => state.userPreferedLanguage, ); @@ -21,41 +21,21 @@ const BeginTheAssignment: React.FC = (props) => { (state) => state.setUserPreferedLanguage, ); const MoveToQuestionPage = () => { - setUserPreferedLanguage(userPreferedLanguage); - const url = - role === "learner" - ? `/learner/${assignmentId}/questions` - : `/learner/${assignmentId}/questions?authorMode=true`; - - window.location.href = url; + if (!disabled) { + setUserPreferedLanguage(userPreferedLanguage); + window.location.href = href; + } }; return (
- + diff --git a/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx b/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx index 8697f086..a782b71a 100644 --- a/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx +++ b/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx @@ -97,6 +97,8 @@ const AboutTheAssignment: FC = ({ allotedTimeMinutes, timeEstimateMinutes, numAttempts = -1, + attemptsBeforeCoolDown = 1, + retakeAttemptCoolDownMinutes = 5, passingGrade, name = "Untitled", id, @@ -141,9 +143,7 @@ const AboutTheAssignment: FC = ({ : getAssignmentState(attempts, numAttempts); const attemptsLeft = - (numAttempts ?? -1) === -1 - ? Infinity - : Math.max(0, numAttempts - attempts.length); + numAttempts === -1 ? Infinity : Math.max(0, numAttempts - attempts.length); const latestAttempt = attempts?.reduce((latest, attempt) => { if (!latest) return attempt; @@ -155,9 +155,80 @@ const AboutTheAssignment: FC = ({ } return latest; }, null); - const latestAttemptDate = latestAttempt - ? new Date(latestAttempt.createdAt).toLocaleString() - : "No attempts yet"; + + const attemptsCount = attempts.length; + const [cooldownMessage, setCooldownMessage] = useState(null); + const [isCooldown, setIsCooldown] = useState(false); + + useEffect(() => { + if ( + !latestAttempt || + attemptsBeforeCoolDown <= 0 || + (attemptsLeft > 0 && attemptsCount < attemptsBeforeCoolDown) || + attemptsLeft === 0 + ) { + setCooldownMessage(null); + setIsCooldown(false); + return; + } + + const finishedAt = new Date(latestAttempt?.expiresAt).getTime(); + const cooldownMs = retakeAttemptCoolDownMinutes * 60_000; + const nextEligibleAt = finishedAt + cooldownMs; + + function updateCountdown() { + const remainingMs = nextEligibleAt - Date.now(); + + if (remainingMs <= 0) { + setCooldownMessage(null); + setIsCooldown(false); + return; + } + + setIsCooldown(true); + + const days = Math.floor(remainingMs / (24 * 60 * 60 * 1000)); + let remainder = remainingMs % (24 * 60 * 60 * 1000); + const hours = Math.floor(remainder / (60 * 60 * 1000)); + remainder %= 60 * 60 * 1000; + const minutes = Math.floor(remainder / 60000); + const seconds = Math.floor((remainder % 60000) / 1000); + + const parts = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + if (minutes) parts.push(`${minutes}m`); + if (seconds) parts.push(`${seconds}s`); + + setCooldownMessage(`Please wait ${parts.join(" ")} before retrying`); + } + + updateCountdown(); + const interval = setInterval(updateCountdown, 1000); + return () => clearInterval(interval); + }, [ + latestAttempt, + attemptsLeft, + attemptsCount, + attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, + ]); + + useEffect(() => { + if (!userPreferedLanguage || languageModalTriggered) { + setToggleLanguageSelectionModal(true); + } + }, [userPreferedLanguage, languageModalTriggered]); + useEffect(() => { + async function fetchLanguages() { + setIsLoading(true); + const supportedLanguages = await getSupportedLanguages(assignmentId); + setLanguages(supportedLanguages); + setIsLoading(false); + } + void fetchLanguages(); + }, [assignmentId]); + const handleConfirm = () => { if (selectedLanguage) { router.replace(`${pathname}?lang=${selectedLanguage}`, undefined); @@ -173,6 +244,37 @@ const AboutTheAssignment: FC = ({ setLanguageModalTriggered(false); setToggleLanguageSelectionModal(false); }; + + const url = + role === "learner" + ? `/learner/${assignmentId}/questions` + : `/learner/${assignmentId}/questions?authorMode=true`; + + const buttonLabel = assignmentState === "in-progress" ? "Resume" : "Begin"; + let buttonMessage = ""; + let buttonDisabled = false; + + if (!role) { + buttonDisabled = true; + buttonMessage = "You must be signed in with a role to begin."; + } else if (role === "learner" && assignmentState === "not-published") { + buttonDisabled = true; + buttonMessage = "The assignment is not published yet."; + } else if (attemptsLeft === 0) { + buttonDisabled = true; + buttonMessage = + "Maximum attempts reached, contact the author to request more."; + } else if (isCooldown && cooldownMessage) { + buttonDisabled = true; + buttonMessage = cooldownMessage; + } else { + buttonMessage = `Click to ${assignmentState === "in-progress" ? "Resume" : "Begin"}`; + } + + const latestAttemptDate = latestAttempt + ? new Date(latestAttempt.createdAt).toLocaleString() + : "No attempts yet"; + return ( <>
@@ -199,22 +301,27 @@ const AboutTheAssignment: FC = ({
+ {isCooldown && cooldownMessage && ( + + ({cooldownMessage}) + + )} @@ -303,10 +410,10 @@ const AboutTheAssignment: FC = ({
diff --git a/apps/web/app/learner/[assignmentId]/questions/LearnerLayout.tsx b/apps/web/app/learner/[assignmentId]/questions/LearnerLayout.tsx index 254e58af..179f5192 100644 --- a/apps/web/app/learner/[assignmentId]/questions/LearnerLayout.tsx +++ b/apps/web/app/learner/[assignmentId]/questions/LearnerLayout.tsx @@ -69,6 +69,18 @@ async function LearnerLayout(props: Props) { ); } + if (attemptId === "in cooldown period") { + return ( + + ); + } + return ( }> +
+ + Attempts Before Cooldown Period: + + + {(selectedVersionDetails || selectedVersion) + .attemptsBeforeCoolDown === 0 + ? "Never wait" + : (( + selectedVersionDetails || + selectedVersion + ).attemptsBeforeCoolDown ?? "")} + +
+
+ + Time Learners Wait Between Attempts (Minutes): + + + {(selectedVersionDetails || selectedVersion) + .attemptsBeforeCoolDown === 0 || + (selectedVersionDetails || selectedVersion) + .retakeAttemptCoolDownMinutes === 0 + ? "Never wait" + : (( + selectedVersionDetails || + selectedVersion + ).retakeAttemptCoolDownMinutes ?? "")} + +
Passing Grade: diff --git a/apps/web/config/constants.ts b/apps/web/config/constants.ts index ea7b7e5d..7183d2d3 100644 --- a/apps/web/config/constants.ts +++ b/apps/web/config/constants.ts @@ -76,19 +76,24 @@ export const stepTwoSections = { title: "3. How will learners complete the assignment?", required: true, }, - feedback: { - title: "4. How much feedback should I give students?", - description: "Choose what feedback Mark gives to students", + retakes: { + title: + "4. How long will learners wait until they can retake the assignment after failed attempts?", required: true, }, - order: { - title: "6. How should questions be presented to the learner?", + feedback: { + title: "5. How much feedback should I give students?", + description: "Choose what feedback Mark gives to students", required: true, }, questionDisplay: { - title: "5. How should the questions be displayed?", + title: "6. How should the questions be displayed?", required: false, }, + order: { + title: "7. How should questions be presented to the learner?", + required: true, + }, } as const; export const formatPricePerMillionTokens = (pricePerToken: number) => { diff --git a/apps/web/config/types.ts b/apps/web/config/types.ts index 74dc0348..c36cb2c7 100644 --- a/apps/web/config/types.ts +++ b/apps/web/config/types.ts @@ -206,6 +206,8 @@ export type AuthorAssignmentState = { gradingCriteriaOverview: string; graded: boolean; numAttempts: number; + attemptsBeforeCoolDown: number; + retakeAttemptCoolDownMinutes: number; allotedTimeMinutes: number; timeEstimateMinutes: number; passingGrade: number; @@ -542,6 +544,8 @@ export type GradingData = { allotedTimeMinutes?: number | undefined; passingGrade: number; numAttempts?: number; + attemptsBeforeCoolDown?: number; + retakeAttemptCoolDownMinutes?: number; displayOrder?: "DEFINED" | "RANDOM"; questionDisplay?: QuestionDisplayType; questionVariationNumber: number; @@ -572,6 +576,8 @@ export type ReplaceAssignmentRequest = { gradingCriteriaOverview?: string; graded: boolean; numAttempts?: number; + attemptsBeforeCoolDown?: number; + retakeAttemptCoolDownMinutes?: number; allotedTimeMinutes?: number; timeEstimateMinutes?: number; passingGrade: number; @@ -636,6 +642,8 @@ export interface AssignmentAttemptWithQuestions extends AssignmentAttempt { export interface AssignmentDetails { allotedTimeMinutes?: number; numAttempts?: number; + attemptsBeforeCoolDown?: number; + retakeAttemptCoolDownMinutes?: number; passingGrade?: number; name: string; questionDisplay?: QuestionDisplayType; diff --git a/apps/web/hooks/useVersionControl.ts b/apps/web/hooks/useVersionControl.ts index 69d0a205..8e1ed06b 100644 --- a/apps/web/hooks/useVersionControl.ts +++ b/apps/web/hooks/useVersionControl.ts @@ -11,6 +11,8 @@ export interface DraftData { questions?: any[]; graded?: boolean; numAttempts?: number; + attemptsBeforeCoolDown?: number; + retakeAttemptCoolDownMinutes?: number; passingGrade?: number; timeEstimateMinutes?: number; allotedTimeMinutes?: number; @@ -267,6 +269,14 @@ export function useVersionControl() { typedDraftData.numAttempts !== undefined ? typedDraftData.numAttempts : assignmentConfigStore.numAttempts, + attemptsBeforeCoolDown: + typedDraftData.attemptsBeforeCoolDown !== undefined + ? typedDraftData.attemptsBeforeCoolDown + : assignmentConfigStore.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: + typedDraftData.retakeAttemptCoolDownMinutes !== undefined + ? typedDraftData.retakeAttemptCoolDownMinutes + : assignmentConfigStore.retakeAttemptCoolDownMinutes, passingGrade: typedDraftData.passingGrade !== undefined ? typedDraftData.passingGrade diff --git a/apps/web/lib/learner.ts b/apps/web/lib/learner.ts index 3c2c7813..9784a462 100644 --- a/apps/web/lib/learner.ts +++ b/apps/web/lib/learner.ts @@ -30,7 +30,7 @@ import { submitReportAuthor } from "@/lib/talkToBackend"; export async function createAttempt( assignmentId: number, cookies?: string, -): Promise { +): Promise { const endpointURL = `${getApiRoutes().assignments}/${assignmentId}/attempts`; try { const res = await fetch(endpointURL, { @@ -40,11 +40,14 @@ export async function createAttempt( ...(cookies ? { Cookie: cookies } : {}), }, }); + console.log("result: ", res); if (!res.ok) { + console.log("result status: ", res.status); const errorBody = (await res.json()) as { message: string }; + console.log("error: ", errorBody); if (res.status === 422) { return "no more attempts"; - } + } else if (res.status === 429) return "in cooldown period"; throw new Error(errorBody.message || "Failed to create attempt"); } const { success, error, id } = (await res.json()) as BaseBackendResponse; diff --git a/apps/web/lib/semantic-versioning.ts b/apps/web/lib/semantic-versioning.ts index d75c807a..21ed4f0d 100644 --- a/apps/web/lib/semantic-versioning.ts +++ b/apps/web/lib/semantic-versioning.ts @@ -78,6 +78,8 @@ export function analyzeChanges( "numberOfQuestionsPerAttempt", "timeLimit", "title", + "attemptsBeforeCoolDown", + "retakeAttemptCoolDownMinutes", ]; comparison.assignmentChanges.forEach((change) => { diff --git a/apps/web/stores/assignmentConfig.ts b/apps/web/stores/assignmentConfig.ts index ce973fb7..9a976aac 100644 --- a/apps/web/stores/assignmentConfig.ts +++ b/apps/web/stores/assignmentConfig.ts @@ -14,6 +14,10 @@ type GradingDataActions = { setQuestionVariationNumber: (questionVariationNumber: number) => void; setGraded: (graded: boolean) => void; setNumAttempts: (numAttempts: number) => void; + setAttemptsBeforeCoolDown: (attemptsBeforeCoolDown: number) => void; + setRetakeAttemptCoolDownMinutes: ( + retakeAttemptCoolDownMinutes: number, + ) => void; setPassingGrade: (passingGrade: number) => void; setTimeEstimateMinutes: (timeEstimateMinutes: number) => void; setAllotedTimeMinutes: (allotedTimeMinutes: number) => void; @@ -35,6 +39,8 @@ export const useAssignmentConfig = createWithEqualityFn< withUpdatedAt((set, get) => ({ errors: {}, numAttempts: -1, + attemptsBeforeCoolDown: 1, + retakeAttemptCoolDownMinutes: 1, passingGrade: 50, displayOrder: "DEFINED", strictTimeLimit: false, @@ -62,6 +68,20 @@ export const useAssignmentConfig = createWithEqualityFn< set({ numAttempts: numAttempts === undefined ? -1 : numAttempts, }), + setAttemptsBeforeCoolDown: (attemptsBeforeCoolDown) => + set({ + attemptsBeforeCoolDown: + attemptsBeforeCoolDown === undefined + ? -1 + : attemptsBeforeCoolDown, + }), + setRetakeAttemptCoolDownMinutes: (retakeAttemptCoolDownMinutes) => + set({ + retakeAttemptCoolDownMinutes: + retakeAttemptCoolDownMinutes === undefined + ? -1 + : retakeAttemptCoolDownMinutes, + }), setPassingGrade: (passingGrade) => set({ passingGrade }), timeEstimateMinutes: undefined, setTimeEstimateMinutes: (timeEstimateMinutes) => @@ -101,6 +121,14 @@ export const useAssignmentConfig = createWithEqualityFn< if (!state.numAttempts || state.numAttempts < -1) { errors.numAttempts = "Please enter a valid number of attempts."; } + if (!(state.attemptsBeforeCoolDown >= 0)) { + errors.attemptsBeforeCoolDown = + "Please enter a valid number of attempts before a cool down period is required."; + } + if (!(state.retakeAttemptCoolDownMinutes >= 0)) { + errors.retakeAttemptCoolDownMinutes = + "Please enter a valid number for the amount of time for a cooldown period in minutes"; + } if ( state.passingGrade === undefined || state.passingGrade <= 0 || @@ -125,6 +153,8 @@ export const useAssignmentConfig = createWithEqualityFn< set(() => ({ errors: {}, numAttempts: -1, + attemptsBeforeCoolDown: 1, + retakeAttemptCoolDownMinutes: 0, passingGrade: 50, displayOrder: "DEFINED", strictTimeLimit: false, diff --git a/apps/web/stores/author.ts b/apps/web/stores/author.ts index 6fe3e14e..86603b6f 100644 --- a/apps/web/stores/author.ts +++ b/apps/web/stores/author.ts @@ -1881,6 +1881,14 @@ export const useAuthorStore = createWithEqualityFn< versionData.numAttempts !== undefined ? versionData.numAttempts : assignmentConfigState.numAttempts, + attemptsBeforeCoolDown: + versionData.attemptsBeforeCoolDown !== undefined + ? versionData.attemptsBeforeCoolDown + : assignmentConfigState.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: + versionData.retakeAttemptCoolDownMinutes !== undefined + ? versionData.retakeAttemptCoolDownMinutes + : assignmentConfigState.retakeAttemptCoolDownMinutes, passingGrade: versionData.passingGrade !== undefined ? versionData.passingGrade @@ -2069,6 +2077,9 @@ export const useAuthorStore = createWithEqualityFn< ...encodedFields, name: state.name, numAttempts: configData.numAttempts, + attemptsBeforeCoolDown: configData.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: + configData.retakeAttemptCoolDownMinutes, passingGrade: configData.passingGrade, displayOrder: configData.displayOrder, graded: configData.graded, @@ -2199,6 +2210,9 @@ export const useAuthorStore = createWithEqualityFn< // Assignment configuration graded: assignmentConfig.graded, numAttempts: assignmentConfig.numAttempts, + attemptsBeforeCoolDown: assignmentConfig.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: + assignmentConfig.retakeAttemptCoolDownMinutes, passingGrade: assignmentConfig.passingGrade, timeEstimateMinutes: assignmentConfig.timeEstimateMinutes, allotedTimeMinutes: assignmentConfig.allotedTimeMinutes, @@ -2377,6 +2391,12 @@ export const useAuthorStore = createWithEqualityFn< numAttempts: assignment.numAttempts || assignmentConfigStore.numAttempts, + attemptsBeforeCoolDown: + assignment.attemptsBeforeCoolDown || + assignmentConfigStore.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: + assignment.retakeAttemptCoolDownMinutes || + assignmentConfigStore.retakeAttemptCoolDownMinutes, passingGrade: assignment.passingGrade || assignmentConfigStore.passingGrade, diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index e98d556b..43859093 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -77,7 +77,6 @@ We follow **semantic conventions** for branches, versions, and PRs: ## Prerequisites Before contributing to this project, ensure the following tools and dependencies are installed: -Thought for 4 seconds **IBM's detect-secrets fork**: