diff --git a/.secrets.baseline b/.secrets.baseline index 49373ff5..0063518f 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": null, "lines": null }, - "generated_at": "2025-10-09T10:36:06Z", + "generated_at": "2025-10-09T13:24:14Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -283,21 +283,21 @@ { "hashed_secret": "e1166e6dd837019ab04f130ab34c425e04161645", "is_verified": false, - "line_number": 382, + "line_number": 384, "type": "Secret Keyword", "verified_result": null }, { "hashed_secret": "f0c5bc5473fd2f959bdac630e625aa33346fd12a", "is_verified": false, - "line_number": 429, + "line_number": 431, "type": "Secret Keyword", "verified_result": null }, { "hashed_secret": "29080f1c58f9859ddaa6aeda7d2c410c12e222dc", "is_verified": false, - "line_number": 461, + "line_number": 463, "type": "Secret Keyword", "verified_result": null } diff --git a/apps/api-gateway/src/auth/jwt/cookie-based/mock.jwt.cookie.auth.guard.ts b/apps/api-gateway/src/auth/jwt/cookie-based/mock.jwt.cookie.auth.guard.ts index ee1dc1e1..e85d75e4 100644 --- a/apps/api-gateway/src/auth/jwt/cookie-based/mock.jwt.cookie.auth.guard.ts +++ b/apps/api-gateway/src/auth/jwt/cookie-based/mock.jwt.cookie.auth.guard.ts @@ -22,15 +22,14 @@ export class MockJwtCookieAuthGuard extends AuthGuard("cookie-strategy") { const request: RequestWithUserSession = context.switchToHttp().getRequest(); request.user = { - userId: "dev-user", - role: UserRole.LEARNER, - groupId: "string", - assignmentId: 1888, + userId: "magdy.hafez@ibm.com1", + role: UserRole.AUTHOR, + groupId: "autogen-faculty-v1-course-v1-IND-AI0103EN-v1", + assignmentId: 1, gradingCallbackRequired: false, returnUrl: "https://skills.network", launch_presentation_locale: "en", }; - return true; } } diff --git a/apps/api-gateway/src/main.ts b/apps/api-gateway/src/main.ts index 5bfd6420..6fdfb62b 100644 --- a/apps/api-gateway/src/main.ts +++ b/apps/api-gateway/src/main.ts @@ -9,36 +9,51 @@ import { AppModule } from "./app.module"; import { winstonOptions } from "./logger/config"; async function bootstrap() { - const app = await NestFactory.create(AppModule, { - cors: false, - logger: WinstonModule.createLogger(winstonOptions), - }); - app.use(json({ limit: "1000mb" })); - app.use(urlencoded({ limit: "1000mb", extended: true })); - app.setGlobalPrefix("api", { - exclude: ["health", "health/liveness", "health/readiness"], - }); - - app.enableVersioning({ - type: VersioningType.URI, - }); - - app.use(helmet()); - - app.use(cookieParser()); - - const config = new DocumentBuilder() - .setTitle("API") - .setDescription("API Description") - .build(); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup("api", app, document, { - customSiteTitle: "API Docs", - customCss: ".swagger-ui .topbar .topbar-wrapper { display: none; }", - }); - - app.enableShutdownHooks(); - - await app.listen(process.env.API_GATEWAY_PORT ?? 3000); + const logger = WinstonModule.createLogger(winstonOptions); + + try { + const app = await NestFactory.create(AppModule, { + cors: false, + logger, + }); + app.use(json({ limit: "1000mb" })); + app.use(urlencoded({ limit: "1000mb", extended: true })); + app.setGlobalPrefix("api", { + exclude: ["health", "health/liveness", "health/readiness"], + }); + + app.enableVersioning({ + type: VersioningType.URI, + }); + + app.use(helmet()); + + app.use(cookieParser()); + + const config = new DocumentBuilder() + .setTitle("API") + .setDescription("API Description") + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup("api", app, document, { + customSiteTitle: "API Docs", + customCss: ".swagger-ui .topbar .topbar-wrapper { display: none; }", + }); + + app.enableShutdownHooks(); + + const port = process.env.API_GATEWAY_PORT ?? 3000; + await app.listen(port, "0.0.0.0"); + + logger.log(`๐Ÿš€ API Gateway is running on port ${port}`); + logger.log( + `๐Ÿ“š API Documentation available at http://localhost:${port}/api`, + ); + } catch (error) { + logger.error("โŒ Failed to start API Gateway:", error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } } + void bootstrap(); diff --git a/apps/api/package.json b/apps/api/package.json index dcf6ebe0..9b104658 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,7 +24,8 @@ "test:staged": "jest --bail --findRelatedTests --passWithNoTests --collectCoverage=false", "test:watch": "jest --watch", "create-initial-versions": "dotenv -e ./dev.env -- ts-node src/scripts/create-initial-versions.ts", - "translation-audit": "node dist/src/scripts/translation-audit.js" + "translation-audit": "node dist/src/scripts/translation-audit.js", + "verify-migration": "dotenv -e ./dev.env -- ts-node scripts/verify-migration.ts" }, "prisma": { "seed": "ts-node prisma/seed.ts" diff --git a/apps/api/prisma/migrations/20251002001400_convert_show_correct_answer_to_enum/migration.sql b/apps/api/prisma/migrations/20251002001400_convert_show_correct_answer_to_enum/migration.sql new file mode 100644 index 00000000..9c77aa84 --- /dev/null +++ b/apps/api/prisma/migrations/20251002001400_convert_show_correct_answer_to_enum/migration.sql @@ -0,0 +1,30 @@ +-- CreateEnum for CorrectAnswerVisibility +CREATE TYPE "CorrectAnswerVisibility" AS ENUM ('NEVER', 'ALWAYS', 'ON_PASS'); + +-- Add the new column with ALWAYS as default +ALTER TABLE "Assignment" ADD COLUMN "correctAnswerVisibility" "CorrectAnswerVisibility" DEFAULT 'ALWAYS'; + +-- Convert existing data: if showCorrectAnswer was true, set to ON_PASS, otherwise set to NEVER +UPDATE "Assignment" +SET "correctAnswerVisibility" = CASE + WHEN "showCorrectAnswer" = true THEN 'ON_PASS'::"CorrectAnswerVisibility" + ELSE 'NEVER'::"CorrectAnswerVisibility" +END; + +-- Make the new column NOT NULL after data conversion +ALTER TABLE "Assignment" ALTER COLUMN "correctAnswerVisibility" SET NOT NULL; + +-- Drop the old column +ALTER TABLE "Assignment" DROP COLUMN "showCorrectAnswer"; + +-- Add the new column to AssignmentVersion table +ALTER TABLE "AssignmentVersion" ADD COLUMN "correctAnswerVisibility" "CorrectAnswerVisibility" DEFAULT 'ALWAYS'; + +-- Update existing AssignmentVersion data: copy from parent Assignment +UPDATE "AssignmentVersion" av +SET "correctAnswerVisibility" = a."correctAnswerVisibility" +FROM "Assignment" a +WHERE av."assignmentId" = a.id; + +-- Make the new column NOT NULL after data conversion +ALTER TABLE "AssignmentVersion" ALTER COLUMN "correctAnswerVisibility" SET NOT NULL; \ No newline at end of file diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e80eb145..fab58ec1 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -5,7 +5,6 @@ generator client { provider = "prisma-client-js" } -// prisma/schema.prisma datasource db { provider = "postgresql" url = env("DATABASE_URL") @@ -23,6 +22,13 @@ enum AssignmentQuestionDisplayOrder { DEFINED /// The assignments are displayed in a predefined order RANDOM /// The assignments are displayed in a random order } + +/// This enum defines when correct answers should be visible to learners +enum CorrectAnswerVisibility { + NEVER /// Correct answers are never shown to learners + ALWAYS /// Correct answers are always shown after submission + ON_PASS /// Correct answers are only shown when learners pass the assignment +} model GradingAudit { id Int @id @default(autoincrement()) questionId Int @@ -82,7 +88,7 @@ model Job { progress String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - result Json? // Add this field to store generated questions + result Json? } model GradingJob { id Int @id @default(autoincrement()) @@ -114,7 +120,7 @@ model publishJob { percentage Int? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - result Json? // Add this field to store generated questions + result Json? } /// This model tracks the AI usage for each assignment and usage type model AIUsage { @@ -188,7 +194,6 @@ enum ChatRole { SYSTEM } -/// The Group model represents a particular group of assignments (having the same groupID) model Group { id String @unique /// Unique ID of the course assignments AssignmentGroup[] /// The list of assignments associated with this group @@ -204,7 +209,6 @@ model AssignmentGroup { @@id([assignmentId, groupId]) /// Unique identifier for the relationship } -/// The Assignment model represents a particular assignment model Assignment { id Int @id @default(autoincrement()) /// Unique identifier for the assignment name String /// Name of the assignment @@ -231,8 +235,8 @@ 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 - showCorrectAnswer Boolean @default(true) /// Should the correct answer be shown to the learner after its submission + showQuestions Boolean @default(true) /// Should the questions be shown to the learner + correctAnswerVisibility CorrectAnswerVisibility @default(ALWAYS) /// When should correct answers be shown to learners updatedAt DateTime @default(now()) @updatedAt /// The DateTime at which the assignment was last updated languageCode String? /// The language code for the assignment currentVersionId Int? /// The ID of the current active version @@ -282,6 +286,7 @@ model AssignmentVersion { showQuestionScore Boolean @default(true) /// Show question score showSubmissionFeedback Boolean @default(true) /// Show submission feedback showQuestions Boolean @default(true) /// Show questions + correctAnswerVisibility CorrectAnswerVisibility @default(ALWAYS) /// When to show correct answers to learners languageCode String? /// Language code createdBy String /// User who created this version createdAt DateTime @default(now()) /// When version was created @@ -523,7 +528,6 @@ model Report { similarityScore Float? // Similarity score (0-1) with the parent report } -// Add to schema.prisma model UserNotification { id Int @id @default(autoincrement()) userId String diff --git a/apps/api/src/api/assignment/attempt/attempt.controller.ts b/apps/api/src/api/assignment/attempt/attempt.controller.ts index 58eb44a6..871d9748 100644 --- a/apps/api/src/api/assignment/attempt/attempt.controller.ts +++ b/apps/api/src/api/assignment/attempt/attempt.controller.ts @@ -27,6 +27,7 @@ import { UserSessionRequest, } from "../../../auth/interfaces/user.session.interface"; import { Roles } from "../../../auth/role/roles.global.guard"; +import { SuccessPageDataDto } from "../../attempt/dto/success-page-data.dto"; import { GRADE_SUBMISSION_EXCEPTION, IN_COOLDOWN_PERIOD, diff --git a/apps/api/src/api/assignment/attempt/attempt.service.ts b/apps/api/src/api/assignment/attempt/attempt.service.ts index 574946bf..0256c0e4 100644 --- a/apps/api/src/api/assignment/attempt/attempt.service.ts +++ b/apps/api/src/api/assignment/attempt/attempt.service.ts @@ -10,6 +10,7 @@ import { } from "@nestjs/common"; import { Assignment, + CorrectAnswerVisibility, Question, QuestionType, QuestionVariant, @@ -19,6 +20,10 @@ import { } from "@prisma/client"; import { JsonValue } from "@prisma/client/runtime/library"; import { LearnerFileUpload } from "src/api/attempt/common/interfaces/attempt.interface"; +import { + SuccessPageDataDto, + SuccessPageQuestionDto, +} from "src/api/attempt/dto/success-page-data.dto"; import { LlmFacadeService } from "src/api/llm/llm-facade.service"; import { PresentationQuestionEvaluateModel } from "src/api/llm/model/presentation.question.evaluate.model"; import { VideoPresentationQuestionEvaluateModel } from "src/api/llm/model/video-presentation.question.evaluate.model"; @@ -43,11 +48,11 @@ import { QuestionService } from "../question/question.service"; import { AssignmentServiceV1 } from "../v1/services/assignment.service"; import { GRADE_SUBMISSION_EXCEPTION, + IN_COOLDOWN_PERIOD, IN_PROGRESS_SUBMISSION_EXCEPTION, 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 { @@ -516,7 +521,7 @@ export class AttemptServiceV1 { grade: 0, showSubmissionFeedback: false, showQuestions: false, - showCorrectAnswer: false, + correctAnswerVisibility: "NEVER", feedbacksForQuestions: [], message: SUBMISSION_DEADLINE_EXCEPTION_MESSAGE, }; @@ -582,7 +587,11 @@ export class AttemptServiceV1 { showSubmissionFeedback: true, showQuestionScore: true, showQuestions: true, - showCorrectAnswer: true, + currentVersion: { + select: { + correctAnswerVisibility: true, + }, + }, questions: { where: { isDeleted: false }, }, @@ -640,7 +649,8 @@ export class AttemptServiceV1 { grade: assignment.showAssignmentScore ? grade : undefined, showSubmissionFeedback: assignment.showSubmissionFeedback, showQuestions: assignment.showQuestions, - showCorrectAnswer: assignment.showCorrectAnswer, + correctAnswerVisibility: + assignment.currentVersion?.correctAnswerVisibility || "NEVER", feedbacksForQuestions: this.constructFeedbacksForQuestions( successfulQuestionResponses, assignment as unknown as LearnerGetAssignmentResponseDto, @@ -661,7 +671,8 @@ export class AttemptServiceV1 { showQuestions: assignment.showQuestions, grade: assignment.showAssignmentScore ? result.grade : undefined, showSubmissionFeedback: assignment.showSubmissionFeedback, - showCorrectAnswer: assignment.showCorrectAnswer, + correctAnswerVisibility: + assignment.currentVersion?.correctAnswerVisibility || "NEVER", feedbacksForQuestions: this.constructFeedbacksForQuestions( successfulQuestionResponses, assignment as unknown as LearnerGetAssignmentResponseDto, @@ -747,7 +758,11 @@ export class AttemptServiceV1 { showSubmissionFeedback: true, showQuestionScore: true, showQuestions: true, - showCorrectAnswer: true, + currentVersion: { + select: { + correctAnswerVisibility: true, + }, + }, }, }); @@ -880,7 +895,8 @@ export class AttemptServiceV1 { // Apply visibility settings for correct answers and if learner didnt pass if ( - assignment.showCorrectAnswer === false && + (assignment.currentVersion?.correctAnswerVisibility || "NEVER") === + "NEVER" && assignmentAttempt.grade < assignment.passingGrade ) { for (const question of finalQuestions) { @@ -907,7 +923,8 @@ export class AttemptServiceV1 { showAssignmentScore: assignment.showAssignmentScore, showSubmissionFeedback: assignment.showSubmissionFeedback, showQuestionScore: assignment.showQuestionScore, - showCorrectAnswer: assignment.showCorrectAnswer, + correctAnswerVisibility: + assignment.currentVersion?.correctAnswerVisibility || "NEVER", comments: assignmentAttempt.comments, }; } @@ -972,7 +989,11 @@ export class AttemptServiceV1 { showAssignmentScore: true, showSubmissionFeedback: true, showQuestionScore: true, - showCorrectAnswer: true, + currentVersion: { + select: { + correctAnswerVisibility: true, + }, + }, }, })) as LearnerGetAssignmentResponseDto; @@ -1154,9 +1175,9 @@ export class AttemptServiceV1 { if (question.choices) { for (const choice of question.choices) { delete choice.points; - // Only remove correct answer data if showCorrectAnswer is false if ( - assignment.showCorrectAnswer === false && + (assignment.currentVersion?.correctAnswerVisibility || "NEVER") === + "ON_PASS" && assignmentAttempt.grade < assignment.passingGrade ) { delete choice.isCorrect; @@ -1171,9 +1192,9 @@ export class AttemptServiceV1 { if (translationObject?.translatedChoices) { for (const choice of translationObject.translatedChoices) { delete choice.points; - // Only remove correct answer data if showCorrectAnswer is false if ( - assignment.showCorrectAnswer === false && + (assignment.currentVersion?.correctAnswerVisibility || + "NEVER") === "ON_PASS" && assignmentAttempt.grade < assignment.passingGrade ) { delete choice.isCorrect; @@ -1193,9 +1214,9 @@ export class AttemptServiceV1 { ) as Choice[]; for (const choice of randomizedArray) { delete choice.points; - // Only remove correct answer data if showCorrectAnswer is false if ( - assignment.showCorrectAnswer === false && + (assignment.currentVersion?.correctAnswerVisibility || "NEVER") === + "ON_PASS" && assignmentAttempt.grade < assignment.passingGrade ) { delete choice.isCorrect; @@ -1205,9 +1226,9 @@ export class AttemptServiceV1 { question.randomizedChoices = JSON.stringify(randomizedArray); } - // Only remove the answer field if showCorrectAnswer is false if ( - assignment.showCorrectAnswer === false && + (assignment.currentVersion?.correctAnswerVisibility || "NEVER") === + "ON_PASS" && assignmentAttempt.grade < assignment.passingGrade ) { delete question.answer; @@ -1228,7 +1249,8 @@ export class AttemptServiceV1 { showSubmissionFeedback: assignment.showSubmissionFeedback, showQuestionScore: assignment.showQuestionScore, showQuestions: assignment.showQuestions, - showCorrectAnswer: assignment.showCorrectAnswer, + correctAnswerVisibility: + assignment.currentVersion?.correctAnswerVisibility || "NEVER", }; } diff --git a/apps/api/src/api/assignment/attempt/dto/assignment-attempt/get.assignment.attempt.response.dto.ts b/apps/api/src/api/assignment/attempt/dto/assignment-attempt/get.assignment.attempt.response.dto.ts index d3f87a8d..060b5837 100644 --- a/apps/api/src/api/assignment/attempt/dto/assignment-attempt/get.assignment.attempt.response.dto.ts +++ b/apps/api/src/api/assignment/attempt/dto/assignment-attempt/get.assignment.attempt.response.dto.ts @@ -1,6 +1,11 @@ import { Optional } from "@nestjs/common"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { QuestionResponse, QuestionType, ResponseType } from "@prisma/client"; +import { + CorrectAnswerVisibility, + QuestionResponse, + QuestionType, + ResponseType, +} from "@prisma/client"; import { Type } from "class-transformer"; import { AttemptQuestionDto } from "src/api/assignment/dto/update.questions.request.dto"; import { Choice } from "../../../question/dto/create.update.question.request.dto"; @@ -91,7 +96,7 @@ export class GetAssignmentAttemptResponseDto extends AssignmentAttemptResponseDt type: Boolean, required: false, }) - showCorrectAnswer: boolean; + correctAnswerVisibility: CorrectAnswerVisibility; @ApiPropertyOptional({ description: "The comments for the question.", diff --git a/apps/api/src/api/assignment/attempt/dto/assignment-attempt/update.assignment.attempt.response.dto.ts b/apps/api/src/api/assignment/attempt/dto/assignment-attempt/update.assignment.attempt.response.dto.ts index 2d899bfc..cf5059f9 100644 --- a/apps/api/src/api/assignment/attempt/dto/assignment-attempt/update.assignment.attempt.response.dto.ts +++ b/apps/api/src/api/assignment/attempt/dto/assignment-attempt/update.assignment.attempt.response.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; +import { CorrectAnswerVisibility } from "@prisma/client"; import { IsArray, IsOptional } from "class-validator"; import type { CreateQuestionResponseAttemptResponseDto } from "../question-response/create.question.response.attempt.response.dto"; import { BaseAssignmentAttemptResponseDto } from "./base.assignment.attempt.response.dto"; @@ -47,7 +48,7 @@ export class UpdateAssignmentAttemptResponseDto extends BaseAssignmentAttemptRes type: Boolean, required: false, }) - showCorrectAnswer: boolean; + correctAnswerVisibility: CorrectAnswerVisibility; @ApiProperty({ description: "The total points earned by the learner.", diff --git a/apps/api/src/api/assignment/dto/get.assignment.response.dto.ts b/apps/api/src/api/assignment/dto/get.assignment.response.dto.ts index e66190ff..7d290e81 100644 --- a/apps/api/src/api/assignment/dto/get.assignment.response.dto.ts +++ b/apps/api/src/api/assignment/dto/get.assignment.response.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, OmitType } from "@nestjs/swagger"; -import { Question } from "@prisma/client"; +import { AssignmentVersion, Question } from "@prisma/client"; import { UpdateAssignmentRequestDto } from "./update.assignment.request.dto"; export class AssignmentResponseDto extends UpdateAssignmentRequestDto { @@ -44,6 +44,7 @@ export class LearnerGetAssignmentResponseDto extends OmitType( GetAssignmentResponseDto, ["questions", "displayOrder"] as const, ) { + currentVersion?: AssignmentVersion; questions: Question[]; displayOrder: string; } 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 13abd426..b88cf963 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 @@ -1,6 +1,7 @@ import { ApiProperty } from "@nestjs/swagger"; import { AssignmentQuestionDisplayOrder, + CorrectAnswerVisibility, QuestionDisplay, } from "@prisma/client"; import { @@ -221,12 +222,9 @@ export class UpdateAssignmentRequestDto { showSubmissionFeedback: boolean; @ApiProperty({ - description: - "Should the correct answer be shown to the learner after its submission", - type: Boolean, + description: "When should correct answers be shown to learners", required: false, }) @IsOptional() - @IsBoolean() - showCorrectAnswer: boolean; + correctAnswerVisibility: CorrectAnswerVisibility; } 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 7477ae4d..1e290b12 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 @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { AssignmentQuestionDisplayOrder, + CorrectAnswerVisibility, QuestionDisplay, QuestionType, ResponseType, @@ -575,8 +576,7 @@ export class UpdateAssignmentQuestionsDto { required: false, }) @IsOptional() - @IsBoolean() - showCorrectAnswer: boolean; + correctAnswerVisibility: CorrectAnswerVisibility; @ApiProperty({ description: "updatedAt", diff --git a/apps/api/src/api/assignment/question/question.controller.ts b/apps/api/src/api/assignment/question/question.controller.ts index e537cb3b..6d8adb68 100644 --- a/apps/api/src/api/assignment/question/question.controller.ts +++ b/apps/api/src/api/assignment/question/question.controller.ts @@ -221,21 +221,37 @@ export class QuestionController { @Param("id") questionId: number, @Body() body: { - question: QuestionDto; selectedLanguage: string; selectedLanguageCode: string; }, - ): Promise<{ translatedQuestion: string; translatedChoices?: Choice[] }> { - const { selectedLanguage, selectedLanguageCode, question } = body; - const { assignmentId } = question; + @Req() request: UserSessionRequest, + ): Promise<{ + translatedQuestion: string; + translatedChoices?: Choice[] | { id: number | null; choice: string }[]; + }> { + const { selectedLanguage, selectedLanguageCode } = body; + const { userSession } = request; + + const question = await this.questionService.findOneForTranslation( + Number(questionId), + ); + const { translatedQuestion, translatedChoices } = await this.questionService.generateTranslationIfNotExists( - assignmentId, + question.assignmentId, question, selectedLanguageCode, selectedLanguage, ); + if (userSession.role === UserRole.LEARNER) { + const safeChoices = translatedChoices?.map((choice) => ({ + id: choice.id || null, + choice: choice.choice, + })); + return { translatedQuestion, translatedChoices: safeChoices }; + } + return { translatedQuestion, translatedChoices }; } } diff --git a/apps/api/src/api/assignment/question/question.service.ts b/apps/api/src/api/assignment/question/question.service.ts index 9ee7657a..25d9598b 100644 --- a/apps/api/src/api/assignment/question/question.service.ts +++ b/apps/api/src/api/assignment/question/question.service.ts @@ -98,6 +98,61 @@ export class QuestionService { }; } + /** + * Find a question for translation purposes - returns basic question data without sensitive information + */ + async findOneForTranslation(id: number): Promise { + if (!id || Number.isNaN(Number(id))) { + throw new NotFoundException(`Question with ID ${id} not found`); + } + + const result = await this.prisma.question.findUnique({ + where: { id }, + select: { + id: true, + question: true, + type: true, + assignmentId: true, + totalPoints: true, + maxWords: true, + maxCharacters: true, + choices: true, + scoring: true, + gradingContextQuestionIds: true, + responseType: true, + isDeleted: true, + randomizedChoices: true, + videoPresentationConfig: true, + liveRecordingConfig: true, + // Exclude 'answer' field to prevent exposure of correct answers + }, + }); + + if (!result) { + throw new NotFoundException(`Question with Id ${id} not found.`); + } + + return { + ...result, + answer: undefined, // Explicitly exclude answer + scoring: result.scoring + ? (result.scoring as unknown as ScoringDto) + : undefined, + choices: result.choices + ? (result.choices as unknown as Choice[]) + : undefined, + assignmentId: result.assignmentId, + videoPresentationConfig: result.videoPresentationConfig + ? (result.videoPresentationConfig as unknown as VideoPresentationConfig) + : undefined, + liveRecordingConfig: result.liveRecordingConfig + ? (result.liveRecordingConfig as unknown as object) + : undefined, + alreadyInBackend: true, + success: true, + }; + } + async update( assignmentId: number, id: number, 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 5282c052..8bd40ccf 100644 --- a/apps/api/src/api/assignment/v2/services/assignment.service.ts +++ b/apps/api/src/api/assignment/v2/services/assignment.service.ts @@ -234,7 +234,7 @@ export class AssignmentServiceV2 { showAssignmentScore: updateDto.showAssignmentScore, showQuestionScore: updateDto.showQuestionScore, showSubmissionFeedback: updateDto.showSubmissionFeedback, - showCorrectAnswer: updateDto.showCorrectAnswer, + correctAnswerVisibility: updateDto.correctAnswerVisibility, timeEstimateMinutes: updateDto.timeEstimateMinutes, showQuestions: updateDto.showQuestions, numberOfQuestionsPerAttempt: updateDto.numberOfQuestionsPerAttempt, 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 11e6c794..04cd2152 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 @@ -63,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; @@ -87,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, @@ -157,7 +157,7 @@ export class DraftManagementService { assignment.displayOrder, questionDisplay: this.parseQuestionDisplay( - saveDraftDto.assignmentData?.questionDisplay + saveDraftDto.assignmentData?.questionDisplay, ) ?? assignment.questionDisplay, numberOfQuestionsPerAttempt: saveDraftDto.assignmentData?.numberOfQuestionsPerAttempt ?? @@ -192,7 +192,7 @@ export class DraftManagementService { { draftId: assignmentDraft.id, userId: userSession.userId, - } + }, ); return { @@ -210,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, @@ -283,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 !== @@ -335,7 +335,7 @@ export class DraftManagementService { async listUserDrafts( assignmentId: number, - userSession: UserSession + userSession: UserSession, ): Promise { // Verify assignment access // await this.verifyAssignmentAccess(assignmentId, userSession); @@ -362,7 +362,7 @@ export class DraftManagementService { async getDraft( draftId: number, - userSession: UserSession + userSession: UserSession, ): Promise<{ id: number; name: string; @@ -467,7 +467,7 @@ export class DraftManagementService { async getLatestDraft( assignmentId: number, - userSession: UserSession + userSession: UserSession, ): Promise<{ id: number; name: string; @@ -520,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 }, @@ -533,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 d5ed2967..8e5dfd62 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 @@ -318,6 +318,7 @@ export class VersionManagementService { showQuestionScore: assignment.showQuestionScore, showSubmissionFeedback: assignment.showSubmissionFeedback, showQuestions: assignment.showQuestions, + correctAnswerVisibility: assignment.correctAnswerVisibility, languageCode: assignment.languageCode, createdBy: userSession.userId, isDraft: createVersionDto.isDraft ?? true, @@ -573,6 +574,7 @@ export class VersionManagementService { showQuestionScore: version.showQuestionScore, showSubmissionFeedback: version.showSubmissionFeedback, showQuestions: version.showQuestions, + correctAnswerVisibility: version.correctAnswerVisibility, languageCode: version.languageCode, // Use the enhanced questionVersions with variants questionVersions: questionVersionsWithVariants, @@ -661,6 +663,7 @@ export class VersionManagementService { showQuestionScore: versionToRestore.showQuestionScore, showSubmissionFeedback: versionToRestore.showSubmissionFeedback, showQuestions: versionToRestore.showQuestions, + correctAnswerVisibility: versionToRestore.correctAnswerVisibility, languageCode: versionToRestore.languageCode, createdBy: userSession.userId, isDraft: true, // Restored versions start as drafts @@ -1110,6 +1113,7 @@ export class VersionManagementService { showQuestionScore: assignment.showQuestionScore, showSubmissionFeedback: assignment.showSubmissionFeedback, showQuestions: assignment.showQuestions, + correctAnswerVisibility: assignment.correctAnswerVisibility, languageCode: assignment.languageCode, }, include: { _count: { select: { questionVersions: true } } }, @@ -1420,6 +1424,7 @@ export class VersionManagementService { showQuestionScore: assignment.showQuestionScore, showSubmissionFeedback: assignment.showSubmissionFeedback, showQuestions: assignment.showQuestions, + correctAnswerVisibility: assignment.correctAnswerVisibility, languageCode: assignment.languageCode, createdBy: userSession.userId, isDraft: true, @@ -1514,6 +1519,7 @@ export class VersionManagementService { showQuestionScore: boolean; showSubmissionFeedback: boolean; showQuestions: boolean; + correctAnswerVisibility: string; languageCode: string | null; }> { // await this.verifyAssignmentAccess(assignmentId, userSession); @@ -1558,6 +1564,7 @@ export class VersionManagementService { showQuestionScore: latestDraft.showQuestionScore, showSubmissionFeedback: latestDraft.showSubmissionFeedback, showQuestions: latestDraft.showQuestions, + correctAnswerVisibility: latestDraft.correctAnswerVisibility, languageCode: latestDraft.languageCode, questions: latestDraft.questionVersions.map((qv) => ({ id: qv.questionId, @@ -1660,6 +1667,7 @@ export class VersionManagementService { showQuestionScore: sourceVersion.showQuestionScore, showSubmissionFeedback: sourceVersion.showSubmissionFeedback, showQuestions: sourceVersion.showQuestions, + correctAnswerVisibility: sourceVersion.correctAnswerVisibility, languageCode: sourceVersion.languageCode, createdBy: userSession.userId, isDraft: true, @@ -2151,6 +2159,9 @@ export class VersionManagementService { draftData.assignmentData.showQuestions ?? assignment.showQuestions ?? true, + correctAnswerVisibility: + draftData.assignmentData.correctAnswerVisibility ?? + assignment.correctAnswerVisibility, languageCode: draftData.assignmentData.languageCode ?? assignment.languageCode, }, 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 024e1293..a4146eb5 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 @@ -963,7 +963,7 @@ export const createMockAssignment = ( questionOrder: [1, 2], published: false, showAssignmentScore: true, - showCorrectAnswer: true, + correctAnswerVisibility: "ON_PASS", showQuestionScore: true, showSubmissionFeedback: true, showQuestions: true, @@ -1066,7 +1066,7 @@ export const createMockUpdateAssignmentDto = ( showQuestionScore: true, showSubmissionFeedback: true, showQuestions: true, - showCorrectAnswer: true, + correctAnswerVisibility: "ON_PASS", ...overrides, }); @@ -1124,7 +1124,7 @@ export const createMockUpdateAssignmentQuestionsDto = ( showQuestionScore: true, showSubmissionFeedback: true, showQuestions: true, - showCorrectAnswer: true, + correctAnswerVisibility: "ON_PASS", versionNumber: "0.0.1", versionDescription: "Updated questions version", updatedAt: new Date(), 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 f8d67d66..65eab65c 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 @@ -365,7 +365,7 @@ describe("AssignmentServiceV2 โ€“ full unit-suite", () => { showQuestionScore: false, showSubmissionFeedback: false, showQuestions: false, - showCorrectAnswer: false, + correctAnswerVisibility: "NEVER", }; expect( diff --git a/apps/api/src/api/attempt/common/utils/attempt-questions-mapper.util.ts b/apps/api/src/api/attempt/common/utils/attempt-questions-mapper.util.ts index b2eb7a4e..d661dcf1 100644 --- a/apps/api/src/api/attempt/common/utils/attempt-questions-mapper.util.ts +++ b/apps/api/src/api/attempt/common/utils/attempt-questions-mapper.util.ts @@ -231,8 +231,6 @@ export class AttemptQuestionsMapper { ? translations.get(variantKey) || {} : {}; - console.log("variantTranslations", variantTranslations); - const questionTranslations = translations.has(questionKey) ? translations.get(questionKey) || {} : {}; diff --git a/apps/api/src/api/attempt/dto/success-page-data.dto.ts b/apps/api/src/api/attempt/dto/success-page-data.dto.ts new file mode 100644 index 00000000..6d051d1e --- /dev/null +++ b/apps/api/src/api/attempt/dto/success-page-data.dto.ts @@ -0,0 +1,80 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class SuccessPageQuestionDto { + @ApiProperty() + id: number; + + @ApiProperty() + number: number; + + @ApiProperty() + question: string; + + @ApiProperty() + type: string; + + @ApiProperty() + totalPoints: number; + + @ApiProperty() + earnedPoints: number; + + @ApiProperty() + learnerResponse?: any; // The actual response given by the user + + @ApiProperty() + choices?: any[]; // For multiple choice questions + + @ApiProperty() + correctAnswer?: any; // Only included if visibility allows + + @ApiProperty() + feedback?: string; // AI/instructor feedback + + @ApiProperty() + scoring?: any; // Rubric info if needed +} + +export class SuccessPageDataDto { + @ApiProperty() + assignmentId: number; + + @ApiProperty() + assignmentName: string; + + @ApiProperty() + isAuthor: boolean; + + @ApiProperty() + grade: number; // Percentage (0-100) + + @ApiProperty() + totalPointsEarned: number; + + @ApiProperty() + totalPointsPossible: number; + + @ApiProperty() + passingGrade: number; + + @ApiProperty() + passed: boolean; + + @ApiProperty() + showQuestions: boolean; + + @ApiProperty() + showSubmissionFeedback: boolean; + + @ApiProperty() + correctAnswerVisibility: "NEVER" | "ALWAYS" | "ON_PASS"; + + @ApiProperty() + comments?: string; // Instructor comments + + @ApiProperty() + preferredLanguage: string; + + @ApiProperty({ type: [SuccessPageQuestionDto] }) + questions: SuccessPageQuestionDto[]; +} diff --git a/apps/api/src/api/attempt/services/attempt-submission.service.ts b/apps/api/src/api/attempt/services/attempt-submission.service.ts index fd7eac05..672eeeed 100644 --- a/apps/api/src/api/attempt/services/attempt-submission.service.ts +++ b/apps/api/src/api/attempt/services/attempt-submission.service.ts @@ -9,6 +9,7 @@ import { import { AssignmentAttempt, AssignmentQuestionDisplayOrder, + CorrectAnswerVisibility, Question, QuestionType, QuestionVariant, @@ -340,7 +341,37 @@ export class AttemptSubmissionService { ); } - // Use version-specific questions if available, otherwise fallback to legacy questions + const assignment = await this.prisma.assignment.findUnique({ + where: { id: assignmentAttempt.assignmentId }, + select: { + questions: true, + questionOrder: true, + displayOrder: true, + passingGrade: true, + showAssignmentScore: true, + showSubmissionFeedback: true, + showQuestionScore: true, + showQuestions: true, + currentVersion: { + select: { + correctAnswerVisibility: true, + }, + }, + }, + }); + + if (!assignment) { + throw new NotFoundException( + `Assignment with Id ${assignmentAttempt.assignmentId} not found.`, + ); + } + + const shouldShowCorrectAnswers = this.shouldShowCorrectAnswers( + assignment.currentVersion?.correctAnswerVisibility || "NEVER", + assignmentAttempt.grade || 0, + assignment.passingGrade, + ); + const questions: unknown[] = assignmentAttempt.assignmentVersionId && assignmentAttempt.assignmentVersion?.questionVersions?.length > 0 @@ -354,7 +385,7 @@ export class AttemptSubmissionService { maxCharacters: qv.maxCharacters, choices: qv.choices, scoring: qv.scoring, - answer: qv.answer, + answer: shouldShowCorrectAnswers ? qv.answer : undefined, gradingContextQuestionIds: qv.gradingContextQuestionIds, responseType: qv.responseType, isDeleted: false, @@ -366,27 +397,6 @@ export class AttemptSubmissionService { where: { assignmentId: assignmentAttempt.assignmentId }, }); - const assignment = await this.prisma.assignment.findUnique({ - where: { id: assignmentAttempt.assignmentId }, - select: { - questions: true, - questionOrder: true, - displayOrder: true, - passingGrade: true, - showAssignmentScore: true, - showSubmissionFeedback: true, - showQuestionScore: true, - showQuestions: true, - showCorrectAnswer: true, - }, - }); - - if (!assignment) { - throw new NotFoundException( - `Assignment with Id ${assignmentAttempt.assignmentId} not found.`, - ); - } - const questionDtos: EnhancedAttemptQuestionDto[] = questions.map((q) => { const question = q as Record; @@ -416,7 +426,7 @@ export class AttemptSubmissionService { showRubricsToLearner: false, rubrics: [], }), - answer: answerValue, + answer: shouldShowCorrectAnswers ? answerValue : undefined, gradingContextQuestionIds: (question.gradingContextQuestionIds as number[]) || [], responseType: (question.responseType as ResponseType) || undefined, @@ -463,13 +473,99 @@ export class AttemptSubmissionService { })), }; + let questionsToShow = questionDtos; + { + // Get all questions from the assignment version or legacy questions + const allQuestions: unknown[] = + assignmentAttempt.assignmentVersionId && + assignmentAttempt.assignmentVersion?.questionVersions?.length > 0 + ? assignmentAttempt.assignmentVersion.questionVersions.map((qv) => ({ + id: qv.questionId || qv.id, + question: qv.question, + type: qv.type, + assignmentId: assignmentAttempt.assignmentId, + totalPoints: qv.totalPoints, + maxWords: qv.maxWords, + maxCharacters: qv.maxCharacters, + choices: qv.choices, + scoring: qv.scoring, + answer: shouldShowCorrectAnswers ? qv.answer : undefined, + gradingContextQuestionIds: qv.gradingContextQuestionIds, + responseType: qv.responseType, + isDeleted: false, + randomizedChoices: qv.randomizedChoices, + videoPresentationConfig: qv.videoPresentationConfig, + liveRecordingConfig: qv.liveRecordingConfig, + })) + : await this.prisma.question.findMany({ + where: { + assignmentId: assignmentAttempt.assignmentId, + isDeleted: false, + }, + }); + + // Convert to questionDtos format + const allQuestionDtos: EnhancedAttemptQuestionDto[] = allQuestions.map( + (q) => { + const question = q as Record; + + const answerValue = + typeof question.answer === "boolean" + ? String(question.answer) + : question.answer !== null && question.answer !== undefined + ? String(question.answer) + : undefined; + + const randomizedChoicesValue: string = + typeof question.randomizedChoices === "string" + ? question.randomizedChoices + : JSON.stringify(question.randomizedChoices ?? false); + + return { + id: question.id as number, + question: question.question as string, + type: question.type as QuestionType, + assignmentId: question.assignmentId as number, + totalPoints: question.totalPoints as number, + maxWords: (question.maxWords as number) || undefined, + maxCharacters: (question.maxCharacters as number) || undefined, + choices: this.parseJsonValue(question.choices, []), + scoring: this.parseJsonValue(question.scoring, { + type: ScoringType.CRITERIA_BASED, + showRubricsToLearner: false, + rubrics: [], + }), + answer: shouldShowCorrectAnswers ? answerValue : undefined, + gradingContextQuestionIds: + (question.gradingContextQuestionIds as number[]) || [], + responseType: (question.responseType as ResponseType) || undefined, + isDeleted: question.isDeleted as boolean, + randomizedChoices: randomizedChoicesValue, + videoPresentationConfig: + this.parseJsonValue( + question.videoPresentationConfig, + null, + ), + liveRecordingConfig: this.parseJsonValue | null>(question.liveRecordingConfig, null), + }; + }, + ); + + questionsToShow = allQuestionDtos; + } + const finalQuestions = await AttemptQuestionsMapper.buildQuestionsWithResponses( formattedAttempt, - questionDtos, + questionsToShow, { id: assignmentAttempt.assignmentId, ...assignment, + // Override questionOrder to include all questions if we should show all + questionOrder: questionsToShow.map((q) => q.id), }, this.prisma, assignmentAttempt.preferredLanguage || undefined, @@ -485,7 +581,8 @@ export class AttemptSubmissionService { showSubmissionFeedback: assignment.showSubmissionFeedback, showQuestions: assignment.showQuestions, showQuestionScore: assignment.showQuestionScore, - showCorrectAnswer: assignment.showCorrectAnswer, + correctAnswerVisibility: + assignment.currentVersion?.correctAnswerVisibility || "NEVER", comments: assignmentAttempt.comments, }; } @@ -537,7 +634,11 @@ export class AttemptSubmissionService { showSubmissionFeedback: true, showQuestions: true, showQuestionScore: true, - showCorrectAnswer: true, + currentVersion: { + select: { + correctAnswerVisibility: true, + }, + }, }, })) as { questions: Question[]; @@ -548,7 +649,9 @@ export class AttemptSubmissionService { showSubmissionFeedback: boolean; showQuestions: boolean; showQuestionScore: boolean; - showCorrectAnswer: boolean; + currentVersion: { + correctAnswerVisibility: CorrectAnswerVisibility; + } | null; }; // Get version-specific questions for translation if available @@ -610,15 +713,39 @@ export class AttemptSubmissionService { })), }; + // Determine if we should show all questions based on correctAnswerVisibility + const shouldShowAllQuestions = this.shouldShowCorrectAnswers( + assignment.currentVersion?.correctAnswerVisibility || "NEVER", + assignmentAttempt.grade || 0, + assignment.passingGrade, + ); + + // If we should show all questions, override the assignment questionOrder + const assignmentForTranslation = { + ...assignment, + questionOrder: shouldShowAllQuestions + ? questionsForTranslation.map((q) => q.id) + : assignment.questionOrder, + questions: questionsForTranslation, + } as unknown as UpdateAssignmentQuestionsDto; + const finalQuestions: AttemptQuestionDto[] = await AttemptQuestionsMapper.buildQuestionsWithTranslations( formattedAttempt, - assignment as unknown as UpdateAssignmentQuestionsDto, + assignmentForTranslation, translations, normalizedLanguage, ); - this.removeSensitiveData(finalQuestions, assignment); + this.removeSensitiveData( + finalQuestions, + { + correctAnswerVisibility: + assignment.currentVersion?.correctAnswerVisibility || "NEVER", + }, + assignmentAttempt.grade || 0, + assignment.passingGrade, + ); return { ...assignmentAttempt, @@ -628,7 +755,8 @@ export class AttemptSubmissionService { showSubmissionFeedback: assignment.showSubmissionFeedback, showQuestionScore: assignment.showQuestionScore, showQuestions: assignment.showQuestions, - showCorrectAnswer: assignment.showCorrectAnswer, + correctAnswerVisibility: + assignment.currentVersion?.correctAnswerVisibility || "NEVER", }; } @@ -694,6 +822,11 @@ export class AttemptSubmissionService { questions: { where: { isDeleted: false }, }, + currentVersion: { + select: { + correctAnswerVisibility: true, + }, + }, }, }); @@ -766,7 +899,8 @@ export class AttemptSubmissionService { grade: assignment.showAssignmentScore ? result.grade : undefined, showQuestions: assignment.showQuestions, showSubmissionFeedback: assignment.showSubmissionFeedback, - showCorrectAnswer: assignment.showCorrectAnswer, + correctAnswerVisibility: + assignment.currentVersion?.correctAnswerVisibility || "NEVER", feedbacksForQuestions: this.gradingService.constructFeedbacksForQuestions( successfulQuestionResponses, @@ -802,6 +936,11 @@ export class AttemptSubmissionService { questions: { where: { isDeleted: false }, }, + currentVersion: { + select: { + correctAnswerVisibility: true, + }, + }, }, }); @@ -853,7 +992,8 @@ export class AttemptSubmissionService { grade: assignment.showAssignmentScore ? grade : undefined, showQuestions: assignment.showQuestions, showSubmissionFeedback: assignment.showSubmissionFeedback, - showCorrectAnswer: assignment.showCorrectAnswer, + correctAnswerVisibility: + assignment.currentVersion?.correctAnswerVisibility || "NEVER", feedbacksForQuestions: this.gradingService.constructFeedbacksForQuestions( successfulQuestionResponses, @@ -897,7 +1037,7 @@ export class AttemptSubmissionService { feedbacksForQuestions: [], message: "The attempt deadline has passed.", showQuestions: false, - showCorrectAnswer: false, + correctAnswerVisibility: "NEVER", }; } @@ -1142,9 +1282,9 @@ export class AttemptSubmissionService { */ private removeSensitiveData( questions: AttemptQuestionDto[], - assignment: { - showCorrectAnswer?: boolean; - }, + assignment: { correctAnswerVisibility: CorrectAnswerVisibility }, + grade: number, + passingGrade: number, ): void { for (const question of questions) { if (!question.scoring?.showRubricsToLearner) { @@ -1154,7 +1294,13 @@ export class AttemptSubmissionService { if (question.choices) { for (const choice of question.choices) { delete choice.points; - if (assignment.showCorrectAnswer === false) { + if ( + !this.shouldShowCorrectAnswers( + assignment.correctAnswerVisibility || "NEVER", + grade, + passingGrade, + ) + ) { delete choice.isCorrect; delete choice.feedback; } @@ -1167,7 +1313,13 @@ export class AttemptSubmissionService { if (translationObject?.translatedChoices) { for (const choice of translationObject.translatedChoices) { delete choice.points; - if (assignment.showCorrectAnswer === false) { + if ( + !this.shouldShowCorrectAnswers( + assignment.correctAnswerVisibility, + grade, + passingGrade, + ) + ) { delete choice.isCorrect; delete choice.feedback; } @@ -1191,7 +1343,13 @@ export class AttemptSubmissionService { if (Array.isArray(randomizedArray)) { for (const choice of randomizedArray) { delete choice.points; - if (assignment.showCorrectAnswer === false) { + if ( + !this.shouldShowCorrectAnswers( + assignment.correctAnswerVisibility || "NEVER", + grade, + passingGrade, + ) + ) { delete choice.isCorrect; delete choice.feedback; } @@ -1236,4 +1394,28 @@ export class AttemptSubmissionService { } return language.toLowerCase().split("-")[0]; } + + /** + * Determine if correct answers should be shown based on visibility setting and grade + */ + private shouldShowCorrectAnswers( + correctAnswerVisibility: CorrectAnswerVisibility, + grade: number, + passingGrade: number, + ): boolean { + switch (correctAnswerVisibility) { + case "NEVER": { + return false; + } + case "ALWAYS": { + return true; + } + case "ON_PASS": { + return grade >= passingGrade; + } + default: { + return false; + } + } + } } diff --git a/apps/api/src/scripts/create-initial-versions.ts b/apps/api/src/scripts/create-initial-versions.ts deleted file mode 100644 index 2cb9362e..00000000 --- a/apps/api/src/scripts/create-initial-versions.ts +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env ts-node - -/** - * Script to create initial versions (version 1) for all existing assignments - * This should be run once to migrate existing assignments to the new version system - */ -import { PrismaService } from "../prisma.service"; - -async function createInitialVersions() { - const prisma = new PrismaService(); - - try { - console.log( - "Starting to create initial versions for existing assignments...", - ); - - const alreadyMigrated = await prisma.assignmentVersion.count(); - if (alreadyMigrated > 0) { - console.log("๐Ÿšซ Initial versions already exist. Skipping migration."); - process.exit(0); - } - - // Find all assignments that don't have any versions yet - const assignmentsWithoutVersions = await prisma.assignment.findMany({ - where: { - versions: { - none: {}, - }, - }, - include: { - questions: { - where: { isDeleted: false }, - }, - AssignmentAuthor: true, - }, - }); - - console.log( - `Found ${assignmentsWithoutVersions.length} assignments without versions`, - ); - - let createdCount = 0; - - for (const assignment of assignmentsWithoutVersions) { - try { - await prisma.$transaction(async (tx) => { - console.log( - `Creating version 1 for assignment "${assignment.name}" (ID: ${assignment.id})`, - ); - - // Create the initial version (version 1) - const assignmentVersion = await tx.assignmentVersion.create({ - data: { - assignmentId: assignment.id, - versionNumber: "1.0.0", - name: assignment.name, - introduction: assignment.introduction, - instructions: assignment.instructions, - gradingCriteriaOverview: assignment.gradingCriteriaOverview, - timeEstimateMinutes: assignment.timeEstimateMinutes, - type: assignment.type, - graded: assignment.graded, - numAttempts: assignment.numAttempts, - attemptsBeforeCoolDown: assignment.attemptsBeforeCoolDown, - retakeAttemptCoolDownMinutes: assignment.retakeAttemptCoolDownMinutes, - allotedTimeMinutes: assignment.allotedTimeMinutes, - attemptsPerTimeRange: assignment.attemptsPerTimeRange, - attemptsTimeRangeHours: assignment.attemptsTimeRangeHours, - passingGrade: assignment.passingGrade, - displayOrder: assignment.displayOrder, - questionDisplay: assignment.questionDisplay, - numberOfQuestionsPerAttempt: - assignment.numberOfQuestionsPerAttempt, - questionOrder: assignment.questionOrder, - published: assignment.published, - showAssignmentScore: assignment.showAssignmentScore, - showQuestionScore: assignment.showQuestionScore, - showSubmissionFeedback: assignment.showSubmissionFeedback, - showQuestions: assignment.showQuestions, - languageCode: assignment.languageCode, - createdBy: assignment.AssignmentAuthor[0]?.userId || "system", - isDraft: false, // Existing published assignments become version 1 (non-draft) - versionDescription: - "Initial version created from existing assignment", - isActive: true, // Make this the active version - }, - }); - - // Create question versions for all questions - for (const [index, question] of assignment.questions.entries()) { - await tx.questionVersion.create({ - data: { - assignmentVersionId: assignmentVersion.id, - questionId: question.id, - totalPoints: question.totalPoints, - type: question.type, - responseType: question.responseType, - question: question.question, - maxWords: question.maxWords, - scoring: question.scoring, - choices: question.choices, - randomizedChoices: question.randomizedChoices, - answer: question.answer, - gradingContextQuestionIds: question.gradingContextQuestionIds, - maxCharacters: question.maxCharacters, - videoPresentationConfig: question.videoPresentationConfig, - liveRecordingConfig: question.liveRecordingConfig, - displayOrder: index + 1, - }, - }); - } - - // Set the assignment's currentVersionId to this new version - await tx.assignment.update({ - where: { id: assignment.id }, - data: { currentVersionId: assignmentVersion.id }, - }); - - // Create version history entry - await tx.versionHistory.create({ - data: { - assignmentId: assignment.id, - toVersionId: assignmentVersion.id, - action: "initial_version_created", - description: "Initial version created during migration", - userId: assignment.AssignmentAuthor[0]?.userId || "system", - }, - }); - - createdCount++; - console.log( - `โœ… Created version 1 for assignment "${assignment.name}" with ${assignment.questions.length} questions`, - ); - }); - } catch (error) { - console.error( - `โŒ Failed to create version for assignment "${assignment.name}" (ID: ${assignment.id}):`, - error, - ); - } - } - - console.log( - `\n๐ŸŽ‰ Successfully created initial versions for ${createdCount} assignments`, - ); - - // Verify the results - const totalVersions = await prisma.assignmentVersion.count(); - const assignmentsWithVersions = await prisma.assignment.count({ - where: { - versions: { - some: {}, - }, - }, - }); - - console.log(`\n๐Ÿ“Š Summary:`); - console.log(`- Total assignment versions in database: ${totalVersions}`); - console.log(`- Assignments with versions: ${assignmentsWithVersions}`); - } catch (error) { - console.error("โŒ Script failed with error:", error); - process.exit(1); - } finally { - await prisma.$disconnect(); - } -} - -// Run the script if called directly -// eslint-disable-next-line unicorn/prefer-module -if (require.main === module) { - createInitialVersions() - .then(() => { - console.log("โœ… Script completed successfully"); - process.exit(0); - }) - .catch((error) => { - console.error("โŒ Script failed:", error); - process.exit(1); - }); -} - -export { createInitialVersions }; diff --git a/apps/web/app/Helpers/checkDiff.ts b/apps/web/app/Helpers/checkDiff.ts index 9ab3b7ac..b1b232d3 100644 --- a/apps/web/app/Helpers/checkDiff.ts +++ b/apps/web/app/Helpers/checkDiff.ts @@ -24,22 +24,30 @@ function safeArrayCompare( b: T[] | null | undefined, compareFn?: (itemA: T, itemB: T) => boolean, ): boolean { - if (a == null && b == null) return true; - if (a == null || b == null) return false; + // Normalize null/undefined to empty arrays for comparison + const normalizeArray = (arr: T[] | null | undefined): T[] => { + if (arr == null) return []; + return arr; + }; - if (a.length !== b.length) return false; + const normalizedA = normalizeArray(a); + const normalizedB = normalizeArray(b); - if (a.length === 0 && b.length === 0) return true; + if (normalizedA.length !== normalizedB.length) return false; + + if (normalizedA.length === 0 && normalizedB.length === 0) return true; if (compareFn) { - for (let i = 0; i < a.length; i++) { - const matchFound = b.some((bItem) => compareFn(a[i], bItem)); + for (let i = 0; i < normalizedA.length; i++) { + const matchFound = normalizedB.some((bItem) => + compareFn(normalizedA[i], bItem), + ); if (!matchFound) return false; } return true; } - return JSON.stringify(a) === JSON.stringify(b); + return JSON.stringify(normalizedA) === JSON.stringify(normalizedB); } export function useChangesSummary(): string { @@ -75,7 +83,7 @@ export function useChangesSummary(): string { showQuestionScore, showAssignmentScore, showQuestions, - showCorrectAnswer, + correctAnswerVisibility, } = useAssignmentFeedbackConfig(); const changesSummary = useMemo(() => { @@ -117,8 +125,8 @@ export function useChangesSummary(): string { if ( !safeCompare( - showCorrectAnswer, - originalAssignment.showCorrectAnswer ?? true, + correctAnswerVisibility, + originalAssignment.correctAnswerVisibility ?? "ALWAYS", ) ) diffs.push("Changed correct answer visibility."); @@ -129,7 +137,7 @@ export function useChangesSummary(): string { } const originalQuestions = originalAssignment.questions || []; - const currentQuestions = questions || []; + const currentQuestions = questions instanceof Array ? questions : []; const addedQuestions = currentQuestions?.filter( (question) => diff --git a/apps/web/app/author/(components)/AuthorQuestionsPage/index.tsx b/apps/web/app/author/(components)/AuthorQuestionsPage/index.tsx index 2b30f00e..7a1a5e08 100644 --- a/apps/web/app/author/(components)/AuthorQuestionsPage/index.tsx +++ b/apps/web/app/author/(components)/AuthorQuestionsPage/index.tsx @@ -419,12 +419,15 @@ const AuthorQuestionsPage: FC = ({ useEffect(() => { const currentQuestionOrder = useAuthorStore.getState().questionOrder; - const newQuestionOrder = questions.map((q) => q.id); + const questionIds = questions.map((q) => q.id); if ( - JSON.stringify(currentQuestionOrder) !== JSON.stringify(newQuestionOrder) + !currentQuestionOrder || + currentQuestionOrder.length === 0 || + currentQuestionOrder.some((id) => !questionIds.includes(id)) || + questionIds.some((id) => !currentQuestionOrder.includes(id)) ) { - useAuthorStore.getState().setQuestionOrder(newQuestionOrder); + useAuthorStore.getState().setQuestionOrder(questionIds); } }, [questions]); @@ -1021,11 +1024,17 @@ const AuthorQuestionsPage: FC = ({ settingsUpdated = true; } if (assignmentData.config.attemptsBeforeCoolDown !== undefined) { - configStore.setAttemptsBeforeCoolDown(assignmentData.config.attemptsBeforeCoolDown); + configStore.setAttemptsBeforeCoolDown( + assignmentData.config.attemptsBeforeCoolDown, + ); settingsUpdated = true; } - if (assignmentData.config.retakeAttemptCoolDownMinutes !== undefined) { - configStore.setRetakeAttemptCoolDownMinutes(assignmentData.config.retakeAttemptCoolDownMinutes); + if ( + assignmentData.config.retakeAttemptCoolDownMinutes !== undefined + ) { + configStore.setRetakeAttemptCoolDownMinutes( + assignmentData.config.retakeAttemptCoolDownMinutes, + ); settingsUpdated = true; } if (assignmentData.config.allotedTimeMinutes !== undefined) { diff --git a/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx b/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx index c1c2112d..c35f4dd5 100644 --- a/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx +++ b/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx @@ -24,13 +24,13 @@ const CheckLearnerSideButton: FC = (props) => { showQuestionScore, showAssignmentScore, showQuestions, - showCorrectAnswer, + correctAnswerVisibility, ] = useAssignmentFeedbackConfig((state) => [ state.showSubmissionFeedback, state.showQuestionScore, state.showAssignmentScore, state.showQuestions, - state.showCorrectAnswer, + state.correctAnswerVisibility, ]); const assignmentConfig = { questionDisplay: assignmentConfigstate.questionDisplay, @@ -48,7 +48,7 @@ const CheckLearnerSideButton: FC = (props) => { showQuestions: showQuestions, showAssignmentScore: showAssignmentScore, showQuestionScore: showQuestionScore, - showCorrectAnswer: showCorrectAnswer, + correctAnswerVisibility: correctAnswerVisibility, instructions: authorState.instructions ?? "", gradingCriteriaOverview: authorState.gradingCriteriaOverview ?? "", showSubmissionFeedback: showSubmissionFeedback, diff --git a/apps/web/app/author/(components)/Header/index.tsx b/apps/web/app/author/(components)/Header/index.tsx index 41d02e95..d62f3fba 100644 --- a/apps/web/app/author/(components)/Header/index.tsx +++ b/apps/web/app/author/(components)/Header/index.tsx @@ -166,13 +166,13 @@ function AuthorHeader() { showQuestionScore, showAssignmentScore, showQuestions, - showCorrectAnswer, + correctAnswerVisibility, ] = useAssignmentFeedbackConfig((state) => [ state.showSubmissionFeedback, state.showQuestionScore, state.showAssignmentScore, state.showQuestions, - state.showCorrectAnswer, + state.correctAnswerVisibility, ]); const role = useAuthorStore((state) => state.role); @@ -286,7 +286,7 @@ function AuthorHeader() { showSubmissionFeedback: newAssignment.showSubmissionFeedback, showQuestionScore: newAssignment.showQuestionScore, showAssignmentScore: newAssignment.showAssignmentScore, - showCorrectAnswer: newAssignment.showCorrectAnswer, + correctAnswerVisibility: newAssignment.correctAnswerVisibility, }); useAuthorStore.getState().setName(newAssignment.name); @@ -524,7 +524,7 @@ function AuthorHeader() { showQuestions, showQuestionScore, showAssignmentScore, - showCorrectAnswer, + correctAnswerVisibility, numberOfQuestionsPerAttempt, questions: questionsAreDifferent ? processQuestions(clonedCurrentQuestions) diff --git a/apps/web/app/author/(components)/StepTwo/AssignmentFeedback.tsx b/apps/web/app/author/(components)/StepTwo/AssignmentFeedback.tsx index 488b7606..48891a22 100644 --- a/apps/web/app/author/(components)/StepTwo/AssignmentFeedback.tsx +++ b/apps/web/app/author/(components)/StepTwo/AssignmentFeedback.tsx @@ -64,7 +64,7 @@ const Component: FC = () => { setShowSubmissionFeedback, setShowQuestionScore, setShowQuestion, - setShowCorrectAnswer, + setCorrectAnswerVisibility, ] = useAssignmentFeedbackConfig((state) => [ state.verbosityLevel, state.setVerbosityLevel, @@ -72,20 +72,20 @@ const Component: FC = () => { state.setShowSubmissionFeedback, state.setShowQuestionScore, state.setShowQuestion, - state.setShowCorrectAnswer, + state.setCorrectAnswerVisibility, ]); const [ showAssignmentScore, showSubmissionFeedback, showQuestionScore, showQuestions, - showCorrectAnswer, + correctAnswerVisibility, ] = useAssignmentFeedbackConfig((state) => [ state.showAssignmentScore, state.showSubmissionFeedback, state.showQuestionScore, state.showQuestions, - state.showCorrectAnswer, + state.correctAnswerVisibility, ]); const handleButtonClick = (verbosity: VerbosityLevels) => { setVerbosityLevel(verbosity); @@ -95,21 +95,21 @@ const Component: FC = () => { setShowSubmissionFeedback(true); setShowQuestionScore(true); setShowQuestion(true); - setShowCorrectAnswer(true); + setCorrectAnswerVisibility("ALWAYS"); break; case "Custom": setShowAssignmentScore(true); setShowSubmissionFeedback(false); setShowQuestionScore(true); setShowQuestion(true); - setShowCorrectAnswer(true); + setCorrectAnswerVisibility("ALWAYS"); break; case "None": setShowAssignmentScore(false); setShowSubmissionFeedback(false); setShowQuestionScore(false); setShowQuestion(false); - setShowCorrectAnswer(false); + setCorrectAnswerVisibility("NEVER"); break; default: break; @@ -121,7 +121,7 @@ const Component: FC = () => { showSubmissionFeedback && showQuestionScore && showQuestions && - showCorrectAnswer + correctAnswerVisibility === "ALWAYS" ) { setVerbosityLevel("Full"); } else if ( @@ -129,7 +129,7 @@ const Component: FC = () => { !showSubmissionFeedback && !showQuestionScore && !showQuestions && - !showCorrectAnswer + correctAnswerVisibility === "NEVER" ) { setVerbosityLevel("None"); } else { @@ -140,7 +140,7 @@ const Component: FC = () => { showSubmissionFeedback, showQuestionScore, showQuestions, - showCorrectAnswer, + correctAnswerVisibility, ]); return ( diff --git a/apps/web/app/author/(components)/StepTwo/FeedbackSettings.tsx b/apps/web/app/author/(components)/StepTwo/FeedbackSettings.tsx index f26bc591..bed1d63c 100644 --- a/apps/web/app/author/(components)/StepTwo/FeedbackSettings.tsx +++ b/apps/web/app/author/(components)/StepTwo/FeedbackSettings.tsx @@ -2,6 +2,7 @@ import { cn } from "@/lib/strings"; import { useAssignmentFeedbackConfig } from "@/stores/assignmentFeedbackConfig"; +import type { CorrectAnswerVisibility } from "@/config/types"; interface SettingItemProps { title: string; @@ -51,18 +52,117 @@ const SettingItem: React.FC = ({ ); }; +interface CorrectAnswerSettingProps { + title: string; + description: string; + value: CorrectAnswerVisibility; + onChange: (value: CorrectAnswerVisibility) => void; +} + +const CorrectAnswerSetting: React.FC = ({ + title, + description, + value, + onChange, +}) => { + const showCorrectAnswers = value !== "NEVER"; + + const handleToggleChange = () => { + if (showCorrectAnswers) { + onChange("NEVER"); + } else { + onChange("ALWAYS"); // Default to ALWAYS when enabling + } + }; + + const radioOptions = [ + { + value: "ON_PASS" as const, + label: "Show only on pass", + description: "Correct answers will only be visible when learners pass", + }, + { + value: "ALWAYS" as const, + label: "Always show", + description: "Correct answers will always be visible after submission", + }, + ]; + + return ( +
+
+
{title}
+
+ {description} +
+ + {/* Radio buttons for when to show (only visible if toggle is enabled) */} + {showCorrectAnswers && ( +
+
+ When to show: +
+ {radioOptions.map((option) => ( + + ))} +
+ )} +
+ + {/* Toggle for enabling/disabling correct answers - positioned on the right side */} +
+ +
+
+ ); +}; + const SettingsContainer: React.FC = () => { const { toggleShowAssignmentScore, toggleShowSubmissionFeedback, toggleShowQuestionScore, toggleShowQuestions, - toggleShowCorrectAnswer, + setCorrectAnswerVisibility, showAssignmentScore, showSubmissionFeedback, showQuestionScore, showQuestions, - showCorrectAnswer, + correctAnswerVisibility, } = useAssignmentFeedbackConfig(); const settingsData = [ @@ -92,13 +192,6 @@ const SettingsContainer: React.FC = () => { value: showQuestions, toggleValue: toggleShowQuestions, }, - { - title: "Show Correct Answers on Pass", - description: - "The correct answer will be visible to the learner only if they pass the assignment.", - value: showCorrectAnswer, - toggleValue: toggleShowCorrectAnswer, - }, ] as const; return (
@@ -107,11 +200,18 @@ const SettingsContainer: React.FC = () => { key={index} title={setting.title} description={setting.description} - lastItem={index === settingsData.length - 1} + lastItem={false} value={setting.value} toggleValue={setting.toggleValue} /> ))} + +
); }; diff --git a/apps/web/app/author/[assignmentId]/review/page.tsx b/apps/web/app/author/[assignmentId]/review/page.tsx index 97d3a810..0baf04d4 100644 --- a/apps/web/app/author/[assignmentId]/review/page.tsx +++ b/apps/web/app/author/[assignmentId]/review/page.tsx @@ -603,67 +603,91 @@ const QuestionChanges = ({ // Check for choices changes (for multiple choice questions) if (changeDetails.some((d) => d.includes("Modified choices"))) { - changes.push( -
-
Choices
-
-
-
- - Before -
-
- {originalQuestion.choices?.map((choice: any, idx: number) => ( -
-
- Choice {idx + 1}: - {choice.choice || "(empty)"} - {choice.isCorrect && ( - - Correct - - )} - - Points: {choice.points} - + // Double-check if there are actually meaningful differences + const originalChoices = originalQuestion.choices || []; + const currentChoices = currentQuestion.choices || []; + + // Only show if there are actual differences in non-empty arrays or meaningful changes + const hasActualChanges = + originalChoices.length > 0 || currentChoices.length > 0; + + if (hasActualChanges) { + changes.push( +
+
Choices
+
+
+
+ + Before +
+
+ {originalChoices.length > 0 ? ( + originalChoices.map((choice: any, idx: number) => ( +
+
+ Choice {idx + 1}: + {choice.choice || "(empty)"} + {choice.isCorrect && ( + + Correct + + )} + + Points: {choice.points} + +
+
+ )) + ) : ( +
+ No choices defined
-
- ))} -
-
-
-
- - After + )} +
-
- {currentQuestion.choices?.map((choice: any, idx: number) => ( -
-
- Choice {idx + 1}: - {choice.choice || "(empty)"} - {choice.isCorrect && ( - - Correct - - )} - - Points: {choice.points} - +
+
+ + + After + +
+
+ {currentChoices.length > 0 ? ( + currentChoices.map((choice: any, idx: number) => ( +
+
+ Choice {idx + 1}: + {choice.choice || "(empty)"} + {choice.isCorrect && ( + + Correct + + )} + + Points: {choice.points} + +
+
+ )) + ) : ( +
+ No choices defined
-
- ))} + )} +
-
-
, - ); +
, + ); + } } // Check for rubric changes @@ -793,14 +817,14 @@ function Component() { showQuestionScore, showAssignmentScore, showQuestions, - showCorrectAnswer, + correctAnswerVisibility, ] = useAssignmentFeedbackConfig((state) => [ state.verbosityLevel, state.showSubmissionFeedback, state.showQuestionScore, state.showAssignmentScore, state.showQuestions, - state.showCorrectAnswer, + state.correctAnswerVisibility, ]); const router = useRouter(); @@ -841,7 +865,7 @@ function Component() { showAssignmentScore: filteredChanges.some((c) => c.includes("Changed assignment score visibility"), ), - showCorrectAnswer: filteredChanges.some((c) => + correctAnswerVisibility: filteredChanges.some((c) => c.includes("Changed correct answer visibility"), ), questionOrder: filteredChanges.some((c) => diff --git a/apps/web/app/chatbot/store/useAuthorContext.ts b/apps/web/app/chatbot/store/useAuthorContext.ts index e276adee..99e369a6 100644 --- a/apps/web/app/chatbot/store/useAuthorContext.ts +++ b/apps/web/app/chatbot/store/useAuthorContext.ts @@ -78,7 +78,7 @@ export const useAuthorContext = (): UseAuthorContextInterface => { }, [setUpdatedAt]); useEffect(() => { - if (questions && focusedQuestionId) { + if (questions && focusedQuestionId && questions instanceof Array) { const focusedQuestion = questions.find((q) => q.id === focusedQuestionId); if (focusedQuestion) { setCurrentQuestion(focusedQuestion); diff --git a/apps/web/app/learner/(components)/Header.tsx b/apps/web/app/learner/(components)/Header.tsx index 57e65cb4..cb16af40 100644 --- a/apps/web/app/learner/(components)/Header.tsx +++ b/apps/web/app/learner/(components)/Header.tsx @@ -187,7 +187,7 @@ function LearnerHeader() { role === "author" ? q.choices ?.map((choice, index) => - q.learnerChoices?.find((c) => String(c) === String(index)) + q.learnerChoices?.includes(String(index)) ? choice.choice : undefined, ) @@ -291,7 +291,9 @@ function LearnerHeader() { }); } clearGithubStore(); - clearLearnerAnswers(); // Clear all learner responses after successful submission + if (role === "learner") { + clearLearnerAnswers(); + } useLearnerStore.getState().setActiveQuestionNumber(null); router.push(`/learner/${assignmentId}/successPage/${res.id}`); diff --git a/apps/web/app/learner/[assignmentId]/successPage/Question.tsx b/apps/web/app/learner/[assignmentId]/successPage/Question.tsx index 16aba0ce..1cc8d120 100644 --- a/apps/web/app/learner/[assignmentId]/successPage/Question.tsx +++ b/apps/web/app/learner/[assignmentId]/successPage/Question.tsx @@ -33,6 +33,7 @@ interface Props { language: string; showSubmissionFeedback?: boolean; showCorrectAnswer?: boolean; + correctAnswerVisibility?: "NEVER" | "ALWAYS" | "ON_PASS"; } interface HighestScoreResponseType { @@ -106,6 +107,7 @@ const Question: FC = ({ language = "en", showSubmissionFeedback, showCorrectAnswer, + correctAnswerVisibility, }) => { const { question: questionText, @@ -483,6 +485,8 @@ const Question: FC = ({ const questionResponse = questionResponses?.[0]; + const shouldShowHighlighting = correctAnswerVisibility !== "NEVER"; + const learnerResponse: LearnerResponseType = (learnerChoices && learnerChoices.length > 0 ? learnerChoices @@ -504,11 +508,13 @@ const Question: FC = ({ return (

0 - ? "bg-yellow-50 border border-yellow-500 rounded p-2" - : "bg-red-50 border border-red-700 rounded p-2" + shouldShowHighlighting + ? highestScoreResponse?.points === totalPoints + ? "bg-green-50 border border-green-500 rounded p-2" + : highestScoreResponse?.points > 0 + ? "bg-yellow-50 border border-yellow-500 rounded p-2" + : "bg-red-50 border border-red-700 rounded p-2" + : "bg-gray-50 border border-gray-300 rounded p-2" }`} > @@ -541,11 +547,15 @@ const Question: FC = ({ (Array.isArray(learnerResponse) && learnerResponse.length === 0) ) { return ( -

+

No answer was provided by the learner. - + {shouldShowHighlighting && ( + + )}

); } @@ -564,12 +574,16 @@ const Question: FC = ({
  • @@ -613,10 +627,12 @@ const Question: FC = ({
  • - {isCorrect && showCorrectAnswer && ( - - )} - {!isCorrect && isSelected && ( + {shouldShowHighlighting && + isCorrect && + showCorrectAnswer && ( + + )} + {shouldShowHighlighting && !isCorrect && isSelected && ( )}
    @@ -630,11 +646,13 @@ const Question: FC = ({ return (

    0 - ? "bg-yellow-50 border border-yellow-500 rounded p-2" - : "bg-red-50 border border-red-700 rounded p-2" + shouldShowHighlighting + ? highestScoreResponse?.points === totalPoints + ? "bg-green-50 border border-green-500 rounded p-2" + : highestScoreResponse?.points > 0 + ? "bg-yellow-50 border border-yellow-500 rounded p-2" + : "bg-red-50 border border-red-700 rounded p-2" + : "bg-gray-50 border border-gray-300 rounded p-2" }`} > {learnerResponse @@ -656,11 +674,13 @@ const Question: FC = ({ return (

    0 - ? "bg-yellow-50 border border-yellow-500 rounded p-2" - : "bg-red-50 border border-red-700 rounded p-2" + shouldShowHighlighting + ? highestScoreResponse?.points === totalPoints + ? "bg-green-50 border border-green-500 rounded p-2" + : highestScoreResponse?.points > 0 + ? "bg-yellow-50 border border-yellow-500 rounded p-2" + : "bg-red-50 border border-red-700 rounded p-2" + : "bg-gray-50 border border-gray-300 rounded p-2" }`} > Transcript: {transcript} @@ -722,11 +742,13 @@ const Question: FC = ({ return (

    0 - ? "bg-yellow-50 border border-yellow-500 rounded p-2" - : "bg-red-50 border border-red-700 rounded p-2" + shouldShowHighlighting + ? highestScoreResponse?.points === totalPoints + ? "bg-green-50 border border-green-500 rounded p-2" + : highestScoreResponse?.points > 0 + ? "bg-yellow-50 border border-yellow-500 rounded p-2" + : "bg-red-50 border border-red-700 rounded p-2" + : "bg-gray-50 border border-gray-300 rounded p-2" }`} > {learnerResponse} @@ -734,19 +756,27 @@ const Question: FC = ({ ); } else { return ( -

    +

    No answer was provided by the learner. - + {shouldShowHighlighting && ( + + )}

    ); } } else { return ( -

    +

    No answer was provided by the learner. - + {shouldShowHighlighting && ( + + )}

    ); } diff --git a/apps/web/app/learner/[assignmentId]/successPage/[submissionId]/page.tsx b/apps/web/app/learner/[assignmentId]/successPage/[submissionId]/page.tsx index f4e90e81..f2559a23 100644 --- a/apps/web/app/learner/[assignmentId]/successPage/[submissionId]/page.tsx +++ b/apps/web/app/learner/[assignmentId]/successPage/[submissionId]/page.tsx @@ -12,6 +12,7 @@ import { import { getCompletedAttempt, getFeedback, + getSuccessPageData, getUser, submitFeedback, submitRegradingRequest, @@ -56,7 +57,9 @@ function SuccessPage() { useState(); const [showSubmissionFeedback, setShowSubmissionFeedback] = useState(false); - const [showCorrectAnswer, setShowCorrectAnswer] = useState(true); + const [correctAnswerVisibility, setCorrectAnswerVisibility] = useState< + "NEVER" | "ALWAYS" | "ON_PASS" + >("ALWAYS"); const [zustandShowSubmissionFeedback, zustandShowQuestions] = useAssignmentDetails((state) => [ state.assignmentDetails?.showSubmissionFeedback ?? true, @@ -120,7 +123,9 @@ function SuccessPage() { setShowSubmissionFeedback( submissionDetails.showSubmissionFeedback || false, ); - setShowCorrectAnswer(submissionDetails.showCorrectAnswer ?? true); + setCorrectAnswerVisibility( + submissionDetails.correctAnswerVisibility ?? "ALWAYS", + ); setShowQuestions(submissionDetails.showQuestions); setUserPreferredLanguage(submissionDetails.preferredLanguage); setGrade(submissionDetails.grade * 100); @@ -160,8 +165,8 @@ function SuccessPage() { setTotalPointsEarned(zustandTotalPointsEarned); setTotalPoints(zustandTotalPoints); setAssignmentDetails(zustandAssignmentDetails); - setShowCorrectAnswer( - zustandAssignmentDetails?.showCorrectAnswer ?? true, + setCorrectAnswerVisibility( + zustandAssignmentDetails?.correctAnswerVisibility ?? "ALWAYS", ); setLoading(false); } else { @@ -606,9 +611,14 @@ function SuccessPage() { question={question} language={userPreferredLanguage} showSubmissionFeedback={showSubmissionFeedback} - showCorrectAnswer={ - !showCorrectAnswer && grade >= passingGrade - } + correctAnswerVisibility={correctAnswerVisibility} + showCorrectAnswer={(() => { + if (correctAnswerVisibility === "NEVER") return false; + if (correctAnswerVisibility === "ALWAYS") return true; + if (correctAnswerVisibility === "ON_PASS") + return grade >= passingGrade; + return false; + })()} /> ))} diff --git a/apps/web/components/version-control/VersionTreeView.tsx b/apps/web/components/version-control/VersionTreeView.tsx index f3f8549f..d60fccb9 100644 --- a/apps/web/components/version-control/VersionTreeView.tsx +++ b/apps/web/components/version-control/VersionTreeView.tsx @@ -863,7 +863,7 @@ export function VersionTreeView({ assignmentId }: Props) {
    -
    +
    {/* Header */} @@ -1561,6 +1561,29 @@ export function VersionTreeView({ assignmentId }: Props) { : "Hidden"}
    +
    + + Correct Answer Visibility: + + + { + (selectedVersionDetails || selectedVersion) + .correctAnswerVisibility + } + +
    diff --git a/apps/web/config/types.ts b/apps/web/config/types.ts index c36cb2c7..1ba0435f 100644 --- a/apps/web/config/types.ts +++ b/apps/web/config/types.ts @@ -218,7 +218,7 @@ export type AuthorAssignmentState = { showQuestionScore: boolean; showSubmissionFeedback: boolean; showQuestions: boolean; - showCorrectAnswer: boolean; + correctAnswerVisibility: CorrectAnswerVisibility; updatedAt: number; numberOfQuestionsPerAttempt?: number; }; @@ -566,7 +566,7 @@ export type FeedbackData = { showQuestions: boolean; showAssignmentScore: boolean; - showCorrectAnswer: boolean; + correctAnswerVisibility: CorrectAnswerVisibility; updatedAt: number | undefined; }; @@ -591,7 +591,7 @@ export type ReplaceAssignmentRequest = { showAssignmentScore?: boolean; showQuestionScore?: boolean; showSubmissionFeedback?: boolean; - showCorrectAnswer?: boolean; + correctAnswerVisibility?: CorrectAnswerVisibility; updatedAt: number; questionVariationNumber?: number; versionDescription?: string; @@ -634,7 +634,7 @@ export interface AssignmentAttemptWithQuestions extends AssignmentAttempt { showAssignmentScore?: boolean; showQuestions?: boolean; showQuestionScore?: boolean; - showCorrectAnswer?: boolean; + correctAnswerVisibility?: CorrectAnswerVisibility; comments?: string; preferredLanguage?: string; } @@ -658,7 +658,7 @@ export interface AssignmentDetails { showAssignmentScore?: boolean; showQuestionScore?: boolean; showSubmissionFeedback?: boolean; - showCorrectAnswer?: boolean; + correctAnswerVisibility?: CorrectAnswerVisibility; numberOfQuestionsPerAttempt?: number; } @@ -671,7 +671,7 @@ export interface AssignmentDetailsLocal extends AssignmentDetails { showAssignmentScore: boolean; showQuestionScore: boolean; showSubmissionFeedback: boolean; - showCorrectAnswer: boolean; + correctAnswerVisibility: CorrectAnswerVisibility; } export type BaseBackendResponse = { @@ -700,6 +700,8 @@ export type LearnerAssignmentState = | "completed"; export type VerbosityLevels = "Full" | "Partial" | "None" | "Custom"; + +export type CorrectAnswerVisibility = "NEVER" | "ALWAYS" | "ON_PASS"; export type VerbosityState = { verbosity: VerbosityLevels; loading: boolean; diff --git a/apps/web/hooks/useUserBehaviorMonitor.ts b/apps/web/hooks/useUserBehaviorMonitor.ts index 237d615c..9d16af50 100644 --- a/apps/web/hooks/useUserBehaviorMonitor.ts +++ b/apps/web/hooks/useUserBehaviorMonitor.ts @@ -110,7 +110,7 @@ export const useUserBehaviorMonitor = ( context = `working on "${assignmentName}"`; } - if (focusedQuestion && totalQuestions > 0) { + if (focusedQuestion && totalQuestions > 0 && contextData.questions) { const questionIndex = contextData.questions?.findIndex( (q) => q.id === focusedQuestion || q.questionId === focusedQuestion, ); diff --git a/apps/web/lib/api-health.ts b/apps/web/lib/api-health.ts new file mode 100644 index 00000000..12e9fbe3 --- /dev/null +++ b/apps/web/lib/api-health.ts @@ -0,0 +1,107 @@ +/** + * API Health Check and Connection Utilities + */ + +const API_GATEWAY_HOST = + process.env.API_GATEWAY_HOST || "http://localhost:8000"; + +/** + * Check if API Gateway is healthy + */ +export async function checkApiHealth(): Promise { + try { + const response = await fetch(`${API_GATEWAY_HOST}/health`, { + method: "GET", + signal: AbortSignal.timeout(5000), // 5 second timeout + }); + return response.ok; + } catch (error) { + console.warn("API health check failed:", error); + return false; + } +} + +/** + * Wait for API Gateway to be ready with exponential backoff + */ +export async function waitForApiReady(maxRetries = 30): Promise { + let retries = 0; + let delay = 1000; // Start with 1 second + + while (retries < maxRetries) { + const isHealthy = await checkApiHealth(); + + if (isHealthy) { + console.log("โœ… API Gateway is ready"); + return true; + } + + retries++; + console.log( + `โณ API Gateway not ready, retry ${retries}/${maxRetries} in ${delay}ms`, + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + + // Exponential backoff with max 5 seconds + delay = Math.min(delay * 1.2, 5000); + } + + console.error( + "โŒ API Gateway failed to become ready after", + maxRetries, + "retries", + ); + return false; +} + +/** + * Make API request with retry logic + */ +export async function apiRequestWithRetry( + url: string, + options: RequestInit = {}, + maxRetries = 3, +): Promise { + let lastError: Error; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url, { + ...options, + signal: AbortSignal.timeout(10000), // 10 second timeout + }); + + // If successful, return immediately + if (response.ok || response.status < 500) { + return response; + } + + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } catch (error) { + lastError = error as Error; + + // Don't retry on certain errors + if ( + error instanceof TypeError && + error.message.includes("Failed to fetch") + ) { + console.warn( + `API request failed on attempt ${attempt}:`, + error.message, + ); + + if (attempt < maxRetries) { + const delay = attempt * 1000; // Linear backoff + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + } + + // For other errors, throw immediately + throw error; + } + } + + throw lastError; +} diff --git a/apps/web/lib/learner.ts b/apps/web/lib/learner.ts index 9784a462..b2df8bec 100644 --- a/apps/web/lib/learner.ts +++ b/apps/web/lib/learner.ts @@ -124,6 +124,49 @@ export async function getCompletedAttempt( } } +/** + * Gets unified success page data for an attempt (works for both authors and learners). + * @param assignmentId The id of the assignment. + * @param attemptId The id of the attempt. + * @param authorData Optional author data from Zustand stores (for authors only). + * @param cookies Optional cookies for authentication. + * @returns Success page data or undefined if error. + */ +export async function getSuccessPageData( + assignmentId: number, + attemptId: number, + authorData?: { + questions: any[]; + grade: number; + totalPointsEarned: number; + totalPointsPossible: number; + responses: any[]; + }, + cookies?: string, +): Promise { + const endpointURL = `${getApiRoutes().assignments}/${assignmentId}/attempts/${attemptId}/success-page-data`; + + try { + const res = await fetch(endpointURL, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(cookies ? { Cookie: cookies } : {}), + }, + body: JSON.stringify(authorData || {}), + }); + if (!res.ok) { + const errorBody = (await res.json()) as { message: string }; + throw new Error(errorBody.message || "Failed to get success page data"); + } + const data = (await res.json()) as any; + return data; + } catch (err) { + console.error("Error fetching success page data:", err); + return undefined; + } +} + /** * Submits an answer for a given assignment, attempt, and question. */ diff --git a/apps/web/lib/talkToBackend.ts b/apps/web/lib/talkToBackend.ts index 7d4d7d65..14ba2602 100644 --- a/apps/web/lib/talkToBackend.ts +++ b/apps/web/lib/talkToBackend.ts @@ -70,6 +70,7 @@ export const submitReportAuthor = apiAuthor.submitReportAuthor; export const createAttempt = apiLearner.createAttempt; export const getAttempt = apiLearner.getAttempt; export const getCompletedAttempt = apiLearner.getCompletedAttempt; +export const getSuccessPageData = apiLearner.getSuccessPageData; export const submitQuestion = apiLearner.submitQuestion; export const getLiveRecordingFeedback = apiLearner.getLiveRecordingFeedback; export const submitAssignment = apiLearner.submitAssignment; diff --git a/apps/web/models/face_expression_model/face_expression_model-weights_manifest.json b/apps/web/models/face_expression_model/face_expression_model-weights_manifest.json index 71f1720c..7b74b5ab 100644 --- a/apps/web/models/face_expression_model/face_expression_model-weights_manifest.json +++ b/apps/web/models/face_expression_model/face_expression_model-weights_manifest.json @@ -1,344 +1 @@ -[ - { - "weights": [ - { - "name": "dense0/conv0/filters", - "shape": [3, 3, 3, 32], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.0057930146946626555, - "min": -0.7125408074435067 - } - }, - { "name": "dense0/conv0/bias", "shape": [32], "dtype": "float32" }, - { - "name": "dense0/conv1/depthwise_filter", - "shape": [3, 3, 32, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.006473719839956246, - "min": -0.6408982641556684 - } - }, - { - "name": "dense0/conv1/pointwise_filter", - "shape": [1, 1, 32, 32], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.010509579321917366, - "min": -1.408283629136927 - } - }, - { "name": "dense0/conv1/bias", "shape": [32], "dtype": "float32" }, - { - "name": "dense0/conv2/depthwise_filter", - "shape": [3, 3, 32, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.005666389652326995, - "min": -0.7252978754978554 - } - }, - { - "name": "dense0/conv2/pointwise_filter", - "shape": [1, 1, 32, 32], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.010316079270605948, - "min": -1.1760330368490781 - } - }, - { "name": "dense0/conv2/bias", "shape": [32], "dtype": "float32" }, - { - "name": "dense0/conv3/depthwise_filter", - "shape": [3, 3, 32, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.0063220320963392074, - "min": -0.853474333005793 - } - }, - { - "name": "dense0/conv3/pointwise_filter", - "shape": [1, 1, 32, 32], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.010322785377502442, - "min": -1.4658355236053466 - } - }, - { "name": "dense0/conv3/bias", "shape": [32], "dtype": "float32" }, - { - "name": "dense1/conv0/depthwise_filter", - "shape": [3, 3, 32, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.0042531527724920535, - "min": -0.5741756242864272 - } - }, - { - "name": "dense1/conv0/pointwise_filter", - "shape": [1, 1, 32, 64], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.010653339647779278, - "min": -1.1825207009035 - } - }, - { "name": "dense1/conv0/bias", "shape": [64], "dtype": "float32" }, - { - "name": "dense1/conv1/depthwise_filter", - "shape": [3, 3, 64, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.005166931012097527, - "min": -0.6355325144879957 - } - }, - { - "name": "dense1/conv1/pointwise_filter", - "shape": [1, 1, 64, 64], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.011478300188101974, - "min": -1.3888743227603388 - } - }, - { "name": "dense1/conv1/bias", "shape": [64], "dtype": "float32" }, - { - "name": "dense1/conv2/depthwise_filter", - "shape": [3, 3, 64, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.006144821410085641, - "min": -0.8479853545918185 - } - }, - { - "name": "dense1/conv2/pointwise_filter", - "shape": [1, 1, 64, 64], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.010541967317169788, - "min": -1.3809977185492421 - } - }, - { "name": "dense1/conv2/bias", "shape": [64], "dtype": "float32" }, - { - "name": "dense1/conv3/depthwise_filter", - "shape": [3, 3, 64, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.005769844849904378, - "min": -0.686611537138621 - } - }, - { - "name": "dense1/conv3/pointwise_filter", - "shape": [1, 1, 64, 64], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.010939095534530341, - "min": -1.2689350820055196 - } - }, - { "name": "dense1/conv3/bias", "shape": [64], "dtype": "float32" }, - { - "name": "dense2/conv0/depthwise_filter", - "shape": [3, 3, 64, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.0037769308277204924, - "min": -0.40790852939381317 - } - }, - { - "name": "dense2/conv0/pointwise_filter", - "shape": [1, 1, 64, 128], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.01188667194516051, - "min": -1.4382873053644218 - } - }, - { "name": "dense2/conv0/bias", "shape": [128], "dtype": "float32" }, - { - "name": "dense2/conv1/depthwise_filter", - "shape": [3, 3, 128, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.006497045825509464, - "min": -0.8381189114907208 - } - }, - { - "name": "dense2/conv1/pointwise_filter", - "shape": [1, 1, 128, 128], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.011632198913424622, - "min": -1.3377028750438316 - } - }, - { "name": "dense2/conv1/bias", "shape": [128], "dtype": "float32" }, - { - "name": "dense2/conv2/depthwise_filter", - "shape": [3, 3, 128, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.005947182225246056, - "min": -0.7969224181829715 - } - }, - { - "name": "dense2/conv2/pointwise_filter", - "shape": [1, 1, 128, 128], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.011436844339557722, - "min": -1.4524792311238306 - } - }, - { "name": "dense2/conv2/bias", "shape": [128], "dtype": "float32" }, - { - "name": "dense2/conv3/depthwise_filter", - "shape": [3, 3, 128, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.006665432686899222, - "min": -0.8998334127313949 - } - }, - { - "name": "dense2/conv3/pointwise_filter", - "shape": [1, 1, 128, 128], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.01283421422920975, - "min": -1.642779421338848 - } - }, - { "name": "dense2/conv3/bias", "shape": [128], "dtype": "float32" }, - { - "name": "dense3/conv0/depthwise_filter", - "shape": [3, 3, 128, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.004711699953266218, - "min": -0.6737730933170692 - } - }, - { - "name": "dense3/conv0/pointwise_filter", - "shape": [1, 1, 128, 256], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.010955964817720302, - "min": -1.3914075318504784 - } - }, - { "name": "dense3/conv0/bias", "shape": [256], "dtype": "float32" }, - { - "name": "dense3/conv1/depthwise_filter", - "shape": [3, 3, 256, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.00554193468654857, - "min": -0.7149095745647656 - } - }, - { - "name": "dense3/conv1/pointwise_filter", - "shape": [1, 1, 256, 256], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.016790372250126858, - "min": -2.484975093018775 - } - }, - { "name": "dense3/conv1/bias", "shape": [256], "dtype": "float32" }, - { - "name": "dense3/conv2/depthwise_filter", - "shape": [3, 3, 256, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.006361540626077091, - "min": -0.8142772001378676 - } - }, - { - "name": "dense3/conv2/pointwise_filter", - "shape": [1, 1, 256, 256], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.01777329678628959, - "min": -1.7062364914838006 - } - }, - { "name": "dense3/conv2/bias", "shape": [256], "dtype": "float32" }, - { - "name": "dense3/conv3/depthwise_filter", - "shape": [3, 3, 256, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.006900275922289082, - "min": -0.8625344902861353 - } - }, - { - "name": "dense3/conv3/pointwise_filter", - "shape": [1, 1, 256, 256], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.015449936717164282, - "min": -1.9003422162112067 - } - }, - { "name": "dense3/conv3/bias", "shape": [256], "dtype": "float32" }, - { - "name": "fc/weights", - "shape": [256, 7], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.004834276554631252, - "min": -0.7203072066400565 - } - }, - { "name": "fc/bias", "shape": [7], "dtype": "float32" } - ], - "paths": ["face_expression_model-shard1"] - } -] +[{"weights":[{"name":"dense0/conv0/filters","shape":[3,3,3,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0057930146946626555,"min":-0.7125408074435067}},{"name":"dense0/conv0/bias","shape":[32],"dtype":"float32"},{"name":"dense0/conv1/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006473719839956246,"min":-0.6408982641556684}},{"name":"dense0/conv1/pointwise_filter","shape":[1,1,32,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.010509579321917366,"min":-1.408283629136927}},{"name":"dense0/conv1/bias","shape":[32],"dtype":"float32"},{"name":"dense0/conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005666389652326995,"min":-0.7252978754978554}},{"name":"dense0/conv2/pointwise_filter","shape":[1,1,32,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.010316079270605948,"min":-1.1760330368490781}},{"name":"dense0/conv2/bias","shape":[32],"dtype":"float32"},{"name":"dense0/conv3/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0063220320963392074,"min":-0.853474333005793}},{"name":"dense0/conv3/pointwise_filter","shape":[1,1,32,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.010322785377502442,"min":-1.4658355236053466}},{"name":"dense0/conv3/bias","shape":[32],"dtype":"float32"},{"name":"dense1/conv0/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0042531527724920535,"min":-0.5741756242864272}},{"name":"dense1/conv0/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.010653339647779278,"min":-1.1825207009035}},{"name":"dense1/conv0/bias","shape":[64],"dtype":"float32"},{"name":"dense1/conv1/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005166931012097527,"min":-0.6355325144879957}},{"name":"dense1/conv1/pointwise_filter","shape":[1,1,64,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.011478300188101974,"min":-1.3888743227603388}},{"name":"dense1/conv1/bias","shape":[64],"dtype":"float32"},{"name":"dense1/conv2/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006144821410085641,"min":-0.8479853545918185}},{"name":"dense1/conv2/pointwise_filter","shape":[1,1,64,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.010541967317169788,"min":-1.3809977185492421}},{"name":"dense1/conv2/bias","shape":[64],"dtype":"float32"},{"name":"dense1/conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005769844849904378,"min":-0.686611537138621}},{"name":"dense1/conv3/pointwise_filter","shape":[1,1,64,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.010939095534530341,"min":-1.2689350820055196}},{"name":"dense1/conv3/bias","shape":[64],"dtype":"float32"},{"name":"dense2/conv0/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0037769308277204924,"min":-0.40790852939381317}},{"name":"dense2/conv0/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.01188667194516051,"min":-1.4382873053644218}},{"name":"dense2/conv0/bias","shape":[128],"dtype":"float32"},{"name":"dense2/conv1/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006497045825509464,"min":-0.8381189114907208}},{"name":"dense2/conv1/pointwise_filter","shape":[1,1,128,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.011632198913424622,"min":-1.3377028750438316}},{"name":"dense2/conv1/bias","shape":[128],"dtype":"float32"},{"name":"dense2/conv2/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005947182225246056,"min":-0.7969224181829715}},{"name":"dense2/conv2/pointwise_filter","shape":[1,1,128,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.011436844339557722,"min":-1.4524792311238306}},{"name":"dense2/conv2/bias","shape":[128],"dtype":"float32"},{"name":"dense2/conv3/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006665432686899222,"min":-0.8998334127313949}},{"name":"dense2/conv3/pointwise_filter","shape":[1,1,128,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.01283421422920975,"min":-1.642779421338848}},{"name":"dense2/conv3/bias","shape":[128],"dtype":"float32"},{"name":"dense3/conv0/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004711699953266218,"min":-0.6737730933170692}},{"name":"dense3/conv0/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.010955964817720302,"min":-1.3914075318504784}},{"name":"dense3/conv0/bias","shape":[256],"dtype":"float32"},{"name":"dense3/conv1/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.00554193468654857,"min":-0.7149095745647656}},{"name":"dense3/conv1/pointwise_filter","shape":[1,1,256,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016790372250126858,"min":-2.484975093018775}},{"name":"dense3/conv1/bias","shape":[256],"dtype":"float32"},{"name":"dense3/conv2/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006361540626077091,"min":-0.8142772001378676}},{"name":"dense3/conv2/pointwise_filter","shape":[1,1,256,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.01777329678628959,"min":-1.7062364914838006}},{"name":"dense3/conv2/bias","shape":[256],"dtype":"float32"},{"name":"dense3/conv3/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006900275922289082,"min":-0.8625344902861353}},{"name":"dense3/conv3/pointwise_filter","shape":[1,1,256,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015449936717164282,"min":-1.9003422162112067}},{"name":"dense3/conv3/bias","shape":[256],"dtype":"float32"},{"name":"fc/weights","shape":[256,7],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004834276554631252,"min":-0.7203072066400565}},{"name":"fc/bias","shape":[7],"dtype":"float32"}],"paths":["face_expression_model-shard1"]}] \ No newline at end of file diff --git a/apps/web/models/tiny_face_detector_model/tiny_face_detector_model-weights_manifest.json b/apps/web/models/tiny_face_detector_model/tiny_face_detector_model-weights_manifest.json index f916e9a5..7d3b222d 100644 --- a/apps/web/models/tiny_face_detector_model/tiny_face_detector_model-weights_manifest.json +++ b/apps/web/models/tiny_face_detector_model/tiny_face_detector_model-weights_manifest.json @@ -1,197 +1 @@ -[ - { - "weights": [ - { - "name": "conv0/filters", - "shape": [3, 3, 3, 16], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.009007044399485869, - "min": -1.2069439495311063 - } - }, - { - "name": "conv0/bias", - "shape": [16], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.005263455241334205, - "min": -0.9211046672334858 - } - }, - { - "name": "conv1/depthwise_filter", - "shape": [3, 3, 16, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.004001977630690033, - "min": -0.5042491814669441 - } - }, - { - "name": "conv1/pointwise_filter", - "shape": [1, 1, 16, 32], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.013836609615999109, - "min": -1.411334180831909 - } - }, - { - "name": "conv1/bias", - "shape": [32], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.0015159862590771096, - "min": -0.30926119685173037 - } - }, - { - "name": "conv2/depthwise_filter", - "shape": [3, 3, 32, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.002666276225856706, - "min": -0.317286870876948 - } - }, - { - "name": "conv2/pointwise_filter", - "shape": [1, 1, 32, 64], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.015265831292844286, - "min": -1.6792414422128714 - } - }, - { - "name": "conv2/bias", - "shape": [64], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.0020280554598453, - "min": -0.37113414915168985 - } - }, - { - "name": "conv3/depthwise_filter", - "shape": [3, 3, 64, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.006100742489683862, - "min": -0.8907084034938438 - } - }, - { - "name": "conv3/pointwise_filter", - "shape": [1, 1, 64, 128], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.016276211832083907, - "min": -2.0508026908425725 - } - }, - { - "name": "conv3/bias", - "shape": [128], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.003394414279975143, - "min": -0.7637432129944072 - } - }, - { - "name": "conv4/depthwise_filter", - "shape": [3, 3, 128, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.006716050119961009, - "min": -0.8059260143953211 - } - }, - { - "name": "conv4/pointwise_filter", - "shape": [1, 1, 128, 256], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.021875603993733724, - "min": -2.8875797271728514 - } - }, - { - "name": "conv4/bias", - "shape": [256], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.0041141652009066415, - "min": -0.8187188749804216 - } - }, - { - "name": "conv5/depthwise_filter", - "shape": [3, 3, 256, 1], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.008423839597141042, - "min": -0.9013508368940915 - } - }, - { - "name": "conv5/pointwise_filter", - "shape": [1, 1, 256, 512], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.030007277283014035, - "min": -3.8709387695088107 - } - }, - { - "name": "conv5/bias", - "shape": [512], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.008402082966823203, - "min": -1.4871686851277068 - } - }, - { - "name": "conv8/filters", - "shape": [1, 1, 512, 25], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.028336129469030042, - "min": -4.675461362389957 - } - }, - { - "name": "conv8/bias", - "shape": [25], - "dtype": "float32", - "quantization": { - "dtype": "uint8", - "scale": 0.002268134028303857, - "min": -0.41053225912299807 - } - } - ], - "paths": ["tiny_face_detector_model-shard1"] - } -] +[{"weights":[{"name":"conv0/filters","shape":[3,3,3,16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009007044399485869,"min":-1.2069439495311063}},{"name":"conv0/bias","shape":[16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005263455241334205,"min":-0.9211046672334858}},{"name":"conv1/depthwise_filter","shape":[3,3,16,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004001977630690033,"min":-0.5042491814669441}},{"name":"conv1/pointwise_filter","shape":[1,1,16,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013836609615999109,"min":-1.411334180831909}},{"name":"conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0015159862590771096,"min":-0.30926119685173037}},{"name":"conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002666276225856706,"min":-0.317286870876948}},{"name":"conv2/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015265831292844286,"min":-1.6792414422128714}},{"name":"conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0020280554598453,"min":-0.37113414915168985}},{"name":"conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006100742489683862,"min":-0.8907084034938438}},{"name":"conv3/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016276211832083907,"min":-2.0508026908425725}},{"name":"conv3/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.003394414279975143,"min":-0.7637432129944072}},{"name":"conv4/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006716050119961009,"min":-0.8059260143953211}},{"name":"conv4/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021875603993733724,"min":-2.8875797271728514}},{"name":"conv4/bias","shape":[256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0041141652009066415,"min":-0.8187188749804216}},{"name":"conv5/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008423839597141042,"min":-0.9013508368940915}},{"name":"conv5/pointwise_filter","shape":[1,1,256,512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.030007277283014035,"min":-3.8709387695088107}},{"name":"conv5/bias","shape":[512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008402082966823203,"min":-1.4871686851277068}},{"name":"conv8/filters","shape":[1,1,512,25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.028336129469030042,"min":-4.675461362389957}},{"name":"conv8/bias","shape":[25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002268134028303857,"min":-0.41053225912299807}}],"paths":["tiny_face_detector_model-shard1"]}] \ No newline at end of file diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 9bd20ec1..9a96d44f 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -18,6 +18,10 @@ const nextConfig = { }, ]; }, + + experimental: { + useDeploymentId: false, + }, }; module.exports = nextConfig; diff --git a/apps/web/stores/assignmentFeedbackConfig.ts b/apps/web/stores/assignmentFeedbackConfig.ts index c18b850b..b2682c46 100644 --- a/apps/web/stores/assignmentFeedbackConfig.ts +++ b/apps/web/stores/assignmentFeedbackConfig.ts @@ -1,4 +1,8 @@ -import type { FeedbackData, VerbosityLevels } from "../config/types"; +import type { + FeedbackData, + VerbosityLevels, + CorrectAnswerVisibility, +} from "../config/types"; import { withUpdatedAt } from "./middlewares"; import { extractAssignmentId } from "@/lib/strings"; import { createJSONStorage, devtools, persist } from "zustand/middleware"; @@ -11,13 +15,14 @@ type FeedbackDataActions = { toggleShowQuestionScore: () => void; toggleShowAssignmentScore: () => void; toggleShowQuestions: () => void; - toggleShowCorrectAnswer: () => void; setShowSubmissionFeedback: (showSubmissionFeedback: boolean) => void; setShowQuestionScore: (showQuestionScore: boolean) => void; setShowQuestion: (showQuestions: boolean) => void; setShowAssignmentScore: (showAssignmentScore: boolean) => void; - setShowCorrectAnswer: (showCorrectAnswer: boolean) => void; + setCorrectAnswerVisibility: ( + correctAnswerVisibility: CorrectAnswerVisibility, + ) => void; setUpdatedAt: (updatedAt: number) => void; setAssignmentFeedbackConfigStore: (state: Partial) => void; @@ -35,27 +40,19 @@ export const useAssignmentFeedbackConfig = createWithEqualityFn< showQuestionScore: true, showAssignmentScore: true, showQuestions: true, - showCorrectAnswer: true, + correctAnswerVisibility: "ALWAYS", setShowQuestion: (showQuestions: boolean) => set({ showQuestions }), toggleShowQuestions: () => set((state) => ({ showQuestions: !state.showQuestions })), - toggleShowCorrectAnswer: () => - set((state) => { - const newValue = !state.showCorrectAnswer; - // Clear localStorage when showCorrectAnswer changes - if (typeof window !== "undefined") { - localStorage.removeItem("questions"); - localStorage.removeItem("assignmentConfig"); - } - return { showCorrectAnswer: newValue }; - }), - setShowCorrectAnswer: (showCorrectAnswer: boolean) => { - // Clear localStorage when showCorrectAnswer changes + setCorrectAnswerVisibility: ( + correctAnswerVisibility: CorrectAnswerVisibility, + ) => { + // Clear localStorage when correctAnswerVisibility changes if (typeof window !== "undefined") { localStorage.removeItem("questions"); localStorage.removeItem("assignmentConfig"); } - set({ showCorrectAnswer }); + set({ correctAnswerVisibility }); }, updatedAt: Date.now(), setVerbosityLevel: (verbosityLevel) => set({ verbosityLevel }), @@ -82,7 +79,8 @@ export const useAssignmentFeedbackConfig = createWithEqualityFn< showSubmissionFeedback: true, showQuestionScore: true, showAssignmentScore: true, - showCorrectAnswer: true, + showQuestions: true, + correctAnswerVisibility: "ALWAYS", updatedAt: Date.now(), })), })), diff --git a/apps/web/stores/author.ts b/apps/web/stores/author.ts index 5c552db8..449df68a 100644 --- a/apps/web/stores/author.ts +++ b/apps/web/stores/author.ts @@ -1915,6 +1915,10 @@ export const useAuthorStore = createWithEqualityFn< useAssignmentFeedbackConfig .getState() .setAssignmentFeedbackConfigStore({ + correctAnswerVisibility: + versionData.correctAnswerVisibility !== undefined + ? versionData.correctAnswerVisibility + : feedbackConfigState.correctAnswerVisibility, showAssignmentScore: versionData.showAssignmentScore !== undefined ? versionData.showAssignmentScore @@ -1994,6 +1998,9 @@ export const useAuthorStore = createWithEqualityFn< gradingCriteriaOverview: decodedVersionData.gradingCriteriaOverview, questions: processedQuestions, + questionOrder: + decodedVersionData.questionOrder || + processedQuestions.map((q) => q.id), checkedOutVersion: versionToCheckout, hasUnsavedChanges: false, }); diff --git a/package.json b/package.json index 2b4cee3a..7b2d5a90 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "build:docker": "docker buildx build -f ./apps/api-gateway/Dockerfile -t mark-api-gateway --no-cache --platform linux/amd64 . &> build-gateway.log && docker buildx build -f ./apps/web/Dockerfile -t mark-web --no-cache --platform linux/amd64 . &> build-web.log && docker buildx build -f ./apps/api/Dockerfile -t mark-api --no-cache --platform linux/amd64 . &> build-api.log", "db": "scripts/start-db.sh", "seed": "source dev.env && docker cp seed.sql mark-postgres:/tmp/seed.sql && docker exec -i mark-postgres pg_restore --username=$POSTGRES_USER --dbname=$POSTGRES_DB --no-owner --no-acl --clean --if-exists --no-password /tmp/seed.sql", - "dev": "dotenv -e dev.env -- turbo run dev --parallel", + "cleanup": "./scripts/cleanup-ports.sh", + "dev": "./scripts/cleanup-ports.sh && dotenv -e dev.env -- turbo run dev --parallel", + "dev:clean": "./scripts/cleanup-ports.sh && yarn dev", "format": "prettier --ignore-unknown --write .", "format:staged": "prettier --ignore-unknown --write \"**/*.{js,jsx,ts,tsx,json,css,scss,md,html}\"", "lint": "turbo run lint -- --fix", diff --git a/scripts/cleanup-ports.sh b/scripts/cleanup-ports.sh new file mode 100755 index 00000000..f4770787 --- /dev/null +++ b/scripts/cleanup-ports.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +echo "๐Ÿงน Cleaning up development ports..." + +PORTS=(3010 4222 8000) + +for port in "${PORTS[@]}"; do + echo "Checking port $port..." + + PIDS=$(lsof -ti:$port 2>/dev/null || true) + + if [ -n "$PIDS" ]; then + echo " Found processes on port $port: $PIDS" + echo " Terminating processes..." + echo "$PIDS" | xargs kill -TERM 2>/dev/null || true + + sleep 2 + + REMAINING=$(lsof -ti:$port 2>/dev/null || true) + if [ -n "$REMAINING" ]; then + echo " Force killing remaining processes..." + echo "$REMAINING" | xargs kill -9 2>/dev/null || true + fi + + echo " โœ… Port $port cleaned" + else + echo " โœ… Port $port is free" + fi +done + +echo "๐ŸŽ‰ Port cleanup complete!" \ No newline at end of file diff --git a/turbo.json b/turbo.json index 3a6849e8..3d6b50ec 100644 --- a/turbo.json +++ b/turbo.json @@ -12,7 +12,8 @@ "seed:update": {}, "lint:staged": {}, "dev": { - "cache": false + "cache": false, + "persistent": true }, "test": { "outputs": ["coverage/**"],