From d70bb7a1e5fcceb5aa56f1256d76a76aca92610c Mon Sep 17 00:00:00 2001 From: wielopolski Date: Fri, 10 Jan 2025 01:09:35 +0100 Subject: [PATCH 1/3] feat: change way of store student answers --- apps/api/.eslintrc.js | 8 ++++++++ apps/api/src/questions/question.service.ts | 12 ++++-------- apps/api/src/seed/nice-data-seeds.ts | 2 +- .../studentLessonProgress.service.ts | 1 + 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/api/.eslintrc.js b/apps/api/.eslintrc.js index d3117d50..06abb6cc 100644 --- a/apps/api/.eslintrc.js +++ b/apps/api/.eslintrc.js @@ -52,4 +52,12 @@ module.exports = { ], "unused-imports/no-unused-imports": "error", }, + overrides: [ + { + files: ["src/seed/**/*.ts", "src/stripe/api/stripe.controller.ts", "test/jest-setup.ts"], + rules: { + "no-console": "off", + }, + }, + ], }; diff --git a/apps/api/src/questions/question.service.ts b/apps/api/src/questions/question.service.ts index ec141c9a..593e80a5 100644 --- a/apps/api/src/questions/question.service.ts +++ b/apps/api/src/questions/question.service.ts @@ -112,7 +112,6 @@ export class QuestionService { const studentAnswer = questionAnswer.answer.filter( (answerOption) => answerOption.answerId === answer.answerId, ); - const answerValueToString = studentAnswer[0].value === true.toString(); if (studentAnswer.length !== 1 || answerValueToString !== answer.isCorrect) { @@ -178,20 +177,17 @@ export class QuestionService { question.type === QUESTION_TYPE.BRIEF_RESPONSE || question.type === QUESTION_TYPE.DETAILED_RESPONSE ? [questionAnswer.answer[0]?.value || ""] - : question.allAnswers.map((answerOption) => { - const studentAnswer = questionAnswer.answer.find( - (answer) => answer.answerId === answerOption.answerId, + : questionAnswer.answer.map((studentAnswer) => { + const answerOption = question.allAnswers.find( + (answer) => answer.answerId === studentAnswer.answerId, ); if (studentAnswer?.value) { return studentAnswer.value; } - if (studentAnswer?.answerId) { - return answerOption.value; - } + return answerOption?.value || "Error"; // TODO: handle it, when value is not found - return ""; }); const formattedAnswer = this.questionAnswerToString(answersToRecord); diff --git a/apps/api/src/seed/nice-data-seeds.ts b/apps/api/src/seed/nice-data-seeds.ts index d873d308..77811a45 100644 --- a/apps/api/src/seed/nice-data-seeds.ts +++ b/apps/api/src/seed/nice-data-seeds.ts @@ -1466,7 +1466,7 @@ export const niceCourses: NiceCourseData[] = [ }, { optionText: "grammatical", - isCorrect: false, + isCorrect: true, }, ], }, diff --git a/apps/api/src/studentLessonProgress/studentLessonProgress.service.ts b/apps/api/src/studentLessonProgress/studentLessonProgress.service.ts index 76e36601..ba45dbac 100644 --- a/apps/api/src/studentLessonProgress/studentLessonProgress.service.ts +++ b/apps/api/src/studentLessonProgress/studentLessonProgress.service.ts @@ -43,6 +43,7 @@ export class StudentLessonProgressService { const [accessCourseLessonWithDetails] = await this.checkLessonAssignment(id, studentId); + // TODO: handle block marking when user is teacher or admin if (!accessCourseLessonWithDetails.isAssigned && !accessCourseLessonWithDetails.isFreemium) throw new UnauthorizedException("You don't have assignment to this lesson"); From 58d73d367221a25f54f61e32f3b92d01d6c8c372 Mon Sep 17 00:00:00 2001 From: wielopolski Date: Fri, 10 Jan 2025 01:12:00 +0100 Subject: [PATCH 2/3] feat: changing hierarchy in stripe directory --- apps/api/src/stripe/{api => }/stripe.controller.ts | 6 +++--- apps/api/src/stripe/stripe.module.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) rename apps/api/src/stripe/{api => }/stripe.controller.ts (94%) diff --git a/apps/api/src/stripe/api/stripe.controller.ts b/apps/api/src/stripe/stripe.controller.ts similarity index 94% rename from apps/api/src/stripe/api/stripe.controller.ts rename to apps/api/src/stripe/stripe.controller.ts index aef62015..b440e14e 100644 --- a/apps/api/src/stripe/api/stripe.controller.ts +++ b/apps/api/src/stripe/stripe.controller.ts @@ -14,9 +14,9 @@ import { Public } from "src/common/decorators/public.decorator"; import { Roles } from "src/common/decorators/roles.decorator"; import { USER_ROLES } from "src/user/schemas/userRoles"; -import { paymentIntentSchema } from "../schemas/payment"; -import { StripeService } from "../stripe.service"; -import { StripeWebhookHandler } from "../stripeWebhook.handler"; +import { paymentIntentSchema } from "./schemas/payment"; +import { StripeService } from "./stripe.service"; +import { StripeWebhookHandler } from "./stripeWebhook.handler"; interface RequestWithRawBody extends Request { rawBody?: string; diff --git a/apps/api/src/stripe/stripe.module.ts b/apps/api/src/stripe/stripe.module.ts index 1d078d62..77e3cf20 100644 --- a/apps/api/src/stripe/stripe.module.ts +++ b/apps/api/src/stripe/stripe.module.ts @@ -5,7 +5,7 @@ import { ConfigService } from "@nestjs/config"; import { LessonModule } from "src/lesson/lesson.module"; import { StatisticsModule } from "src/statistics/statistics.module"; -import { StripeController } from "./api/stripe.controller"; +import { StripeController } from "./stripe.controller"; import { StripeService } from "./stripe.service"; import { StripeWebhookHandler } from "./stripeWebhook.handler"; From 94147f66f7835fe8f8dbc0ea48fada8e2c6443e5 Mon Sep 17 00:00:00 2001 From: wielopolski Date: Fri, 10 Jan 2025 02:23:51 +0100 Subject: [PATCH 3/3] feat: refactor lesson service --- apps/api/.eslintrc.js | 2 +- apps/api/src/chapter/chapter.type.ts | 16 --- .../repositories/adminChapter.repository.ts | 4 +- apps/api/src/common/states.ts | 5 - apps/api/src/courses/course.service.ts | 29 +--- apps/api/src/file/file.constants.ts | 2 + apps/api/src/file/file.service.ts | 14 +- apps/api/src/lesson/lesson.schema.ts | 10 +- .../lesson/repositories/lesson.repository.ts | 50 +++++-- .../api/src/lesson/services/lesson.service.ts | 134 ++---------------- apps/api/src/questions/question.repository.ts | 107 +++++++++++++- apps/api/src/swagger/api-schema.json | 24 ++-- apps/web/app/api/generated-api.ts | 2 +- 13 files changed, 184 insertions(+), 215 deletions(-) delete mode 100644 apps/api/src/chapter/chapter.type.ts delete mode 100644 apps/api/src/common/states.ts create mode 100644 apps/api/src/file/file.constants.ts diff --git a/apps/api/.eslintrc.js b/apps/api/.eslintrc.js index 06abb6cc..58d09b89 100644 --- a/apps/api/.eslintrc.js +++ b/apps/api/.eslintrc.js @@ -54,7 +54,7 @@ module.exports = { }, overrides: [ { - files: ["src/seed/**/*.ts", "src/stripe/api/stripe.controller.ts", "test/jest-setup.ts"], + files: ["src/seed/**/*.ts", "src/stripe/stripe.controller.ts", "test/jest-setup.ts"], rules: { "no-console": "off", }, diff --git a/apps/api/src/chapter/chapter.type.ts b/apps/api/src/chapter/chapter.type.ts deleted file mode 100644 index a55d8f07..00000000 --- a/apps/api/src/chapter/chapter.type.ts +++ /dev/null @@ -1,16 +0,0 @@ -// TODO: remove unused types -export const LESSON_ITEM_TYPE = { - text_block: { key: "text_block", value: "Text Block" }, - file: { key: "file", value: "File" }, - question: { key: "question", value: "Question" }, -} as const; - -export const LESSON_FILE_TYPE = { - presentation: { key: "presentation", value: "Presentation" }, - external_presentation: { - key: "external_presentation", - value: "External Presentation", - }, - video: { key: "video", value: "Video" }, - external_video: { key: "external_video", value: "External Video" }, -} as const; diff --git a/apps/api/src/chapter/repositories/adminChapter.repository.ts b/apps/api/src/chapter/repositories/adminChapter.repository.ts index 3b054edc..2c72b81b 100644 --- a/apps/api/src/chapter/repositories/adminChapter.repository.ts +++ b/apps/api/src/chapter/repositories/adminChapter.repository.ts @@ -6,7 +6,7 @@ import { chapters, courses, lessons, questionAnswerOptions, questions } from "sr import type { UpdateChapterBody } from "../schemas/chapter.schema"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import type { AdminLessonWithContentSchema, QuestionSchema } from "src/lesson/lesson.schema"; +import type { AdminLessonWithContentSchema, AdminQuestionBody } from "src/lesson/lesson.schema"; import type { LessonTypes } from "src/lesson/lesson.type"; import type * as schema from "src/storage/schema"; @@ -82,7 +82,7 @@ export class AdminChapterRepository { fileType: sql`${lessons.fileType}`, displayOrder: sql`${lessons.displayOrder}`, isExternal: sql`${lessons.isExternal}`, - questions: sql` + questions: sql` ( SELECT ARRAY( SELECT json_build_object( diff --git a/apps/api/src/common/states.ts b/apps/api/src/common/states.ts deleted file mode 100644 index 90e1b259..00000000 --- a/apps/api/src/common/states.ts +++ /dev/null @@ -1,5 +0,0 @@ -// TODO: remove -export const STATES = { - draft: "draft", - published: "published", -} as const; diff --git a/apps/api/src/courses/course.service.ts b/apps/api/src/courses/course.service.ts index 34cb4c3b..e2597c37 100644 --- a/apps/api/src/courses/course.service.ts +++ b/apps/api/src/courses/course.service.ts @@ -423,13 +423,7 @@ export class CourseService { ) .orderBy(chapters.displayOrder); - // TODO: temporary fix - const getImageUrl = async (url: string) => { - if (!url || url.startsWith("https://")) return url ?? ""; - return await this.fileService.getFileUrl(url); - }; - - const thumbnailUrl = await getImageUrl(course.thumbnailS3Key); + const thumbnailUrl = await this.fileService.getFileUrl(course.thumbnailS3Key); return { ...course, @@ -480,12 +474,7 @@ export class CourseService { .where(and(eq(chapters.courseId, id), isNotNull(chapters.title))) .orderBy(chapters.displayOrder); - const getImageUrl = async (url: string) => { - if (!url || url.startsWith("https://")) return url; - return await this.fileService.getFileUrl(url); - }; - - const thumbnailS3SingedUrl = await getImageUrl(course.thumbnailS3Key); + const thumbnailS3SingedUrl = await this.fileService.getFileUrl(course.thumbnailS3Key); const updatedCourseLessonList = await Promise.all( courseChapterList?.map(async (chapter) => { @@ -547,12 +536,7 @@ export class CourseService { ) .orderBy(chapters.displayOrder); - const getImageUrl = async (url: string) => { - if (!url || url.startsWith("https://")) return url; - return await this.fileService.getFileUrl(url); - }; - - const thumbnailS3SingedUrl = await getImageUrl(course.thumbnailS3Key); + const thumbnailS3SingedUrl = await this.fileService.getFileUrl(course.thumbnailS3Key); return { ...course, @@ -591,11 +575,6 @@ export class CourseService { conditions.push(inArray(courses.id, availableCourseIds)); } - const getImageUrl = async (url: string) => { - if (!url || url.startsWith("https://")) return url; - return await this.fileService.getFileUrl(url); - }; - const teacherCourses = await this.db .select({ id: courses.id, @@ -649,7 +628,7 @@ export class CourseService { teacherCourses.map(async (course) => ({ ...course, thumbnailUrl: course.thumbnailUrl - ? await getImageUrl(course.thumbnailUrl) + ? await this.fileService.getFileUrl(course.thumbnailUrl) : course.thumbnailUrl, })), ); diff --git a/apps/api/src/file/file.constants.ts b/apps/api/src/file/file.constants.ts new file mode 100644 index 00000000..103b17b5 --- /dev/null +++ b/apps/api/src/file/file.constants.ts @@ -0,0 +1,2 @@ +export const REDIS_TTL = 59 * 60 * 1000; +export const MAX_FILE_SIZE = 20 * 1024 * 1024; diff --git a/apps/api/src/file/file.service.ts b/apps/api/src/file/file.service.ts index f4d8719e..ad6d9240 100644 --- a/apps/api/src/file/file.service.ts +++ b/apps/api/src/file/file.service.ts @@ -2,6 +2,8 @@ import { BadRequestException, ConflictException, Inject, Injectable } from "@nes import { S3Service } from "src/s3/s3.service"; +import { MAX_FILE_SIZE, REDIS_TTL } from "./file.constants"; + import type { createCache } from "cache-manager"; @Injectable() @@ -12,14 +14,15 @@ export class FileService { ) {} async getFileUrl(fileKey: string): Promise { + if (fileKey.startsWith("https://")) return fileKey; + try { const cachedUrl = await this.cacheManager.get(fileKey); if (cachedUrl) return cachedUrl; const signedUrl = await this.s3Service.getSignedUrl(fileKey); - // TODO: extract to config constant - const ttl = 59 * 60 * 1000; - await this.cacheManager.set(fileKey, signedUrl, ttl); + + await this.cacheManager.set(fileKey, signedUrl, REDIS_TTL); return signedUrl; } catch (error) { @@ -33,7 +36,6 @@ export class FileService { throw new BadRequestException("No file uploaded"); } - const maxSize = 50 * 1024 * 1024; // 50MB const allowedMimeTypes = [ "image/jpeg", "image/png", @@ -43,9 +45,9 @@ export class FileService { "video/quicktime", ]; - if (file.size > maxSize) { + if (file.size > MAX_FILE_SIZE) { throw new BadRequestException( - `File size exceeds the maximum allowed size of ${maxSize} bytes`, + `File size exceeds the maximum allowed size of ${MAX_FILE_SIZE} bytes`, ); } diff --git a/apps/api/src/lesson/lesson.schema.ts b/apps/api/src/lesson/lesson.schema.ts index af247a80..e0534aee 100644 --- a/apps/api/src/lesson/lesson.schema.ts +++ b/apps/api/src/lesson/lesson.schema.ts @@ -41,15 +41,11 @@ export const optionSchema = Type.Object({ }); export const questionSchema = Type.Object({ + ...adminQuestionSchema.properties, id: UUIDSchema, - type: Type.Enum(QUESTION_TYPE), - description: Type.Optional(Type.Union([Type.String(), Type.Null()])), - title: Type.String(), - displayOrder: Type.Optional(Type.Number()), - photoS3Key: Type.Optional(Type.Union([Type.String(), Type.Null()])), + solutionExplanation: Type.Union([Type.String(), Type.Null()]), options: Type.Optional(Type.Array(optionSchema)), passQuestion: Type.Union([Type.Boolean(), Type.Null()]), - solutionExplanation: Type.Union([Type.String(), Type.Null()]), }); export const lessonSchema = Type.Object({ @@ -161,12 +157,10 @@ export type CreateLessonBody = Static; export type UpdateLessonBody = Static; export type UpdateQuizLessonBody = Static; export type CreateQuizLessonBody = Static; -// TODO: duplicate export type OptionBody = Static; export type AdminOptionBody = Static; export type AdminQuestionBody = Static; export type QuestionBody = Static; -export type QuestionSchema = Static; export type LessonShow = Static; export type LessonSchema = Static; export type AnswerQuestionBody = Static; diff --git a/apps/api/src/lesson/repositories/lesson.repository.ts b/apps/api/src/lesson/repositories/lesson.repository.ts index 8c7545b0..f832b13b 100644 --- a/apps/api/src/lesson/repositories/lesson.repository.ts +++ b/apps/api/src/lesson/repositories/lesson.repository.ts @@ -7,6 +7,7 @@ import { courses, lessons, questions, + quizAttempts, studentCourses, studentLessonProgress, } from "src/storage/schema"; @@ -41,21 +42,21 @@ export class LessonRepository { isFreemium: sql`${chapters.isFreemium}`, isEnrolled: sql`CASE WHEN ${studentCourses.id} IS NULL THEN FALSE ELSE TRUE END`, nextLessonId: sql` - COALESCE( - ( - SELECT l2.id - FROM ${lessons} l2 - JOIN ${chapters} c ON c.id = l2.chapter_id - LEFT JOIN ${studentLessonProgress} slp ON slp.lesson_id = l2.id AND slp.student_id = ${userId} - WHERE c.course_id = ${chapters.courseId} - AND l2.id != ${lessons.id} - AND slp.completed_at IS NULL - ORDER BY c.display_order, l2.display_order - LIMIT 1 - ), - NULL - ) - `, + COALESCE( + ( + SELECT l2.id + FROM ${lessons} l2 + JOIN ${chapters} c ON c.id = l2.chapter_id + LEFT JOIN ${studentLessonProgress} slp ON slp.lesson_id = l2.id AND slp.student_id = ${userId} + WHERE c.course_id = ${chapters.courseId} + AND l2.id != ${lessons.id} + AND slp.completed_at IS NULL + ORDER BY c.display_order, l2.display_order + LIMIT 1 + ), + NULL + ) + `, }) .from(lessons) .leftJoin(chapters, eq(chapters.id, lessons.chapterId)) @@ -244,6 +245,25 @@ export class LessonRepository { .where(and(eq(chapters.isPublished, true), eq(lessons.id, id))); } + async getQuizResult(lessonId: UUIDType, quizScore: number, userId: UUIDType) { + return await this.db + .select({ + score: sql`${quizAttempts.score}`, + correctAnswerCount: sql`${quizAttempts.correctAnswers}`, + wrongAnswerCount: sql`${quizAttempts.wrongAnswers}`, + }) + .from(quizAttempts) + .where( + and( + eq(quizAttempts.lessonId, lessonId), + eq(quizAttempts.userId, userId), + eq(quizAttempts.score, quizScore), + ), + ) + .orderBy(desc(quizAttempts.createdAt)) + .limit(1); + } + // async retireQuizProgress( // courseId: UUIDType, // lessonId: UUIDType, diff --git a/apps/api/src/lesson/services/lesson.service.ts b/apps/api/src/lesson/services/lesson.service.ts index 16b76cc7..03c881f6 100644 --- a/apps/api/src/lesson/services/lesson.service.ts +++ b/apps/api/src/lesson/services/lesson.service.ts @@ -6,7 +6,6 @@ import { UnauthorizedException, } from "@nestjs/common"; import { EventBus } from "@nestjs/cqrs"; -import { and, desc, eq, sql } from "drizzle-orm"; import { isNumber } from "lodash"; import { DatabasePg } from "src/common"; @@ -14,13 +13,6 @@ import { QuizCompletedEvent } from "src/events"; import { FileService } from "src/file/file.service"; import { QuestionRepository } from "src/questions/question.repository"; import { QuestionService } from "src/questions/question.service"; -import { QUESTION_TYPE } from "src/questions/schema/question.types"; -import { - questionAnswerOptions, - questions, - quizAttempts, - studentQuestionAnswers, -} from "src/storage/schema"; import { StudentLessonProgressService } from "src/studentLessonProgress/studentLessonProgress.service"; import { LESSON_TYPES } from "../lesson.type"; @@ -29,12 +21,10 @@ import { LessonRepository } from "../repositories/lesson.repository"; import type { AnswerQuestionBody, LessonShow, - OptionBody, QuestionBody, QuestionDetails, } from "../lesson.schema"; import type { UUIDType } from "src/common"; -import type { QuestionType } from "src/questions/schema/question.types"; @Injectable() export class LessonService { @@ -72,102 +62,11 @@ export class LessonService { } } - const questionList: QuestionBody[] = await this.db - .select({ - id: sql`${questions.id}`, - type: sql`${questions.type}`, - title: questions.title, - description: sql`${questions.description}`, - solutionExplanation: sql`CASE - WHEN ${lesson.quizCompleted} THEN ${questions.solutionExplanation} - ELSE NULL - END`, - photoS3Key: sql`${questions.photoS3Key}`, - passQuestion: sql`CASE - WHEN ${lesson.quizCompleted} THEN ${studentQuestionAnswers.isCorrect} - ELSE NULL END`, - displayOrder: sql`${questions.displayOrder}`, - options: sql`CASE - WHEN ${questions.type} in (${QUESTION_TYPE.BRIEF_RESPONSE}, ${ - QUESTION_TYPE.DETAILED_RESPONSE - }) AND ${lesson.quizCompleted} THEN - ARRAY[json_build_object( - 'id', ${studentQuestionAnswers.id}, - 'optionText', '', - 'isCorrect', TRUE, - 'displayOrder', 1, - 'isStudentAnswer', TRUE, - 'studentAnswer', ${studentQuestionAnswers.answer}->>'1' - )] - ELSE - ( - SELECT ARRAY( - SELECT json_build_object( - 'id', qao.id, - 'optionText', - CASE - WHEN ${!lesson.quizCompleted} AND ${questions.type} = ${ - QUESTION_TYPE.FILL_IN_THE_BLANKS_TEXT - } THEN NULL - ELSE qao.option_text - END, - 'isCorrect', CASE WHEN ${lesson.quizCompleted} THEN qao.is_correct ELSE NULL END, - 'displayOrder', - CASE - WHEN ${lesson.quizCompleted} THEN qao.display_order - ELSE NULL - END, - 'isStudentAnswer', - CASE - WHEN ${studentQuestionAnswers.id} IS NULL THEN NULL - WHEN ${ - studentQuestionAnswers.answer - }->>CAST(qao.display_order AS text) = qao.option_text AND - ${questions.type} IN (${QUESTION_TYPE.FILL_IN_THE_BLANKS_DND}, ${ - QUESTION_TYPE.FILL_IN_THE_BLANKS_TEXT - }) - THEN TRUE - WHEN EXISTS ( - SELECT 1 - FROM jsonb_object_keys(${studentQuestionAnswers.answer}) AS key - WHERE ${studentQuestionAnswers.answer}->key = to_jsonb(qao.option_text)) - AND ${questions.type} NOT IN (${QUESTION_TYPE.FILL_IN_THE_BLANKS_DND}, ${ - QUESTION_TYPE.FILL_IN_THE_BLANKS_TEXT - }) - THEN TRUE - ELSE FALSE - END, - 'studentAnswer', - CASE - WHEN ${studentQuestionAnswers.id} IS NULL THEN NULL - ELSE ${studentQuestionAnswers.answer}->>CAST(qao.display_order AS text) - END - ) - FROM ${questionAnswerOptions} qao - WHERE qao.question_id = questions.id - ORDER BY - CASE - WHEN ${questions.type} in (${ - QUESTION_TYPE.FILL_IN_THE_BLANKS_DND - }) AND ${!lesson.quizCompleted} - THEN random() - ELSE qao.display_order - END - ) - ) - END - `, - }) - .from(questions) - .leftJoin( - studentQuestionAnswers, - and( - eq(studentQuestionAnswers.questionId, questions.id), - eq(studentQuestionAnswers.studentId, userId), - ), - ) - .where(eq(questions.lessonId, id)) - .orderBy(questions.displayOrder); + const questionList = await this.questionRepository.getQuestionsForLesson( + lesson.id, + lesson.quizCompleted, + userId, + ); const questionListWithUrls: QuestionBody[] = await Promise.all( questionList.map(async (question) => { @@ -187,22 +86,11 @@ export class LessonService { ); if (isStudent && lesson.quizCompleted && isNumber(lesson.quizScore)) { - const [quizResult] = await this.db - .select({ - score: sql`${quizAttempts.score}`, - correctAnswerCount: sql`${quizAttempts.correctAnswers}`, - wrongAnswerCount: sql`${quizAttempts.wrongAnswers}`, - }) - .from(quizAttempts) - .where( - and( - eq(quizAttempts.lessonId, id), - eq(quizAttempts.userId, userId), - eq(quizAttempts.score, lesson.quizScore), - ), - ) - .orderBy(desc(quizAttempts.createdAt)) - .limit(1); + const [quizResult] = await this.lessonRepository.getQuizResult( + lesson.id, + lesson.quizScore, + userId, + ); const quizDetails: QuestionDetails = { questions: questionListWithUrls, @@ -306,7 +194,7 @@ export class LessonService { throw new ConflictException( "Quiz evaluation failed, problem with question: " + error?.message + - " problem: " + + " problem is: " + error?.response?.error, ); } diff --git a/apps/api/src/questions/question.repository.ts b/apps/api/src/questions/question.repository.ts index 6ddd5a4d..71c39c30 100644 --- a/apps/api/src/questions/question.repository.ts +++ b/apps/api/src/questions/question.repository.ts @@ -9,15 +9,120 @@ import { studentQuestionAnswers, } from "src/storage/schema"; +import { QUESTION_TYPE, type QuestionType } from "./schema/question.types"; + import type { AnswerQuestionSchema, QuestionSchema } from "./schema/question.schema"; -import type { QuestionType } from "./schema/question.types"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import type { OptionBody, QuestionBody } from "src/lesson/lesson.schema"; import type * as schema from "src/storage/schema"; @Injectable() export class QuestionRepository { constructor(@Inject("DB") private readonly db: DatabasePg) {} + async getQuestionsForLesson( + lessonId: UUIDType, + isCompleted: boolean, + userId: UUIDType, + ): Promise { + return await this.db + .select({ + id: sql`${questions.id}`, + type: sql`${questions.type}`, + title: questions.title, + description: sql`${questions.description}`, + solutionExplanation: sql`CASE + WHEN ${isCompleted} THEN ${questions.solutionExplanation} + ELSE NULL + END`, + photoS3Key: sql`${questions.photoS3Key}`, + passQuestion: sql`CASE + WHEN ${isCompleted} THEN ${studentQuestionAnswers.isCorrect} + ELSE NULL END`, + displayOrder: sql`${questions.displayOrder}`, + options: sql`CASE + WHEN ${questions.type} in (${QUESTION_TYPE.BRIEF_RESPONSE}, ${ + QUESTION_TYPE.DETAILED_RESPONSE + }) AND ${isCompleted} THEN + ARRAY[json_build_object( + 'id', ${studentQuestionAnswers.id}, + 'optionText', '', + 'isCorrect', TRUE, + 'displayOrder', 1, + 'isStudentAnswer', TRUE, + 'studentAnswer', ${studentQuestionAnswers.answer}->>'1' + )] + ELSE + ( + SELECT ARRAY( + SELECT json_build_object( + 'id', qao.id, + 'optionText', + CASE + WHEN ${!isCompleted} AND ${questions.type} = ${ + QUESTION_TYPE.FILL_IN_THE_BLANKS_TEXT + } THEN NULL + ELSE qao.option_text + END, + 'isCorrect', CASE WHEN ${isCompleted} THEN qao.is_correct ELSE NULL END, + 'displayOrder', + CASE + WHEN ${isCompleted} THEN qao.display_order + ELSE NULL + END, + 'isStudentAnswer', + CASE + WHEN ${studentQuestionAnswers.id} IS NULL THEN NULL + WHEN ${ + studentQuestionAnswers.answer + }->>CAST(qao.display_order AS text) = qao.option_text AND + ${questions.type} IN (${QUESTION_TYPE.FILL_IN_THE_BLANKS_DND}, ${ + QUESTION_TYPE.FILL_IN_THE_BLANKS_TEXT + }) + THEN TRUE + WHEN EXISTS ( + SELECT 1 + FROM jsonb_object_keys(${studentQuestionAnswers.answer}) AS key + WHERE ${studentQuestionAnswers.answer}->key = to_jsonb(qao.option_text)) + AND ${questions.type} NOT IN (${ + QUESTION_TYPE.FILL_IN_THE_BLANKS_DND + }, ${QUESTION_TYPE.FILL_IN_THE_BLANKS_TEXT}) + THEN TRUE + ELSE FALSE + END, + 'studentAnswer', + CASE + WHEN ${studentQuestionAnswers.id} IS NULL THEN NULL + ELSE ${studentQuestionAnswers.answer}->>CAST(qao.display_order AS text) + END + ) + FROM ${questionAnswerOptions} qao + WHERE qao.question_id = questions.id + ORDER BY + CASE + WHEN ${questions.type} in (${ + QUESTION_TYPE.FILL_IN_THE_BLANKS_DND + }) AND ${!isCompleted} + THEN random() + ELSE qao.display_order + END + ) + ) + END + `, + }) + .from(questions) + .leftJoin( + studentQuestionAnswers, + and( + eq(studentQuestionAnswers.questionId, questions.id), + eq(studentQuestionAnswers.studentId, userId), + ), + ) + .where(eq(questions.lessonId, lessonId)) + .orderBy(questions.displayOrder); + } + async getQuestions( answerQuestion: AnswerQuestionSchema, trx?: PostgresJsDatabase, diff --git a/apps/api/src/swagger/api-schema.json b/apps/api/src/swagger/api-schema.json index a3c3ac0f..69e99853 100644 --- a/apps/api/src/swagger/api-schema.json +++ b/apps/api/src/swagger/api-schema.json @@ -6040,6 +6040,16 @@ "displayOrder": { "type": "number" }, + "solutionExplanation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "photoS3Key": { "anyOf": [ { @@ -6133,24 +6143,14 @@ "type": "null" } ] - }, - "solutionExplanation": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] } }, "required": [ "id", "type", "title", - "passQuestion", - "solutionExplanation" + "solutionExplanation", + "passQuestion" ] } }, diff --git a/apps/web/app/api/generated-api.ts b/apps/web/app/api/generated-api.ts index ba21c978..11eaa06c 100644 --- a/apps/web/app/api/generated-api.ts +++ b/apps/web/app/api/generated-api.ts @@ -954,6 +954,7 @@ export interface GetLessonByIdResponse { description?: string | null; title: string; displayOrder?: number; + solutionExplanation: string | null; photoS3Key?: string | null; options?: { /** @format uuid */ @@ -967,7 +968,6 @@ export interface GetLessonByIdResponse { questionId?: string; }[]; passQuestion: boolean | null; - solutionExplanation: string | null; }[]; questionCount: number; correctAnswerCount: number | null;