diff --git a/apps/api/src/chapter/adminChapter.service.ts b/apps/api/src/chapter/adminChapter.service.ts index a965531d..6e457ca3 100644 --- a/apps/api/src/chapter/adminChapter.service.ts +++ b/apps/api/src/chapter/adminChapter.service.ts @@ -63,7 +63,7 @@ export class AdminChapterService { // }, // }; // } - // async processLessonItems(lessonItemsList: LessonItemWithContentSchema[]) { + // async processLessonItems(lessonItemsList: AdminLessonWithContentSchema[]) { // const getFileUrl = async (url: string) => { // if (!url || url.startsWith("https://")) return url; // return await this.s3Service.getSignedUrl(url); @@ -123,7 +123,7 @@ export class AdminChapterService { // const lessonItemsList = await this.adminChapterRepository.getLessonItems(id); - // const items = await this.processLessonItems(lessonItemsList as LessonItemWithContentSchema[]); + // const items = await this.processLessonItems(lessonItemsList as AdminLessonWithContentSchema[]); // return { // ...lesson, diff --git a/apps/api/src/chapter/chapter.service.ts b/apps/api/src/chapter/chapter.service.ts index ddee3e15..bc634f7e 100644 --- a/apps/api/src/chapter/chapter.service.ts +++ b/apps/api/src/chapter/chapter.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable, NotFoundException, UnauthorizedException } from "@n import { EventBus } from "@nestjs/cqrs"; import { DatabasePg } from "src/common"; -import { LessonRepository } from "src/lesson/lesson.repository"; +import { LessonRepository } from "src/lesson/repositories/lesson.repository"; import { ChapterRepository } from "./repositories/chapter.repository"; diff --git a/apps/api/src/chapter/chapter.type.ts b/apps/api/src/chapter/chapter.type.ts index 83d50ab3..a55d8f07 100644 --- a/apps/api/src/chapter/chapter.type.ts +++ b/apps/api/src/chapter/chapter.type.ts @@ -1,8 +1,4 @@ -export const LESSON_TYPE = { - quiz: { key: "quiz", value: "Quiz" }, - multimedia: { key: "multimedia", value: "Multimedia" }, -} as const; - +// TODO: remove unused types export const LESSON_ITEM_TYPE = { text_block: { key: "text_block", value: "Text Block" }, file: { key: "file", value: "File" }, diff --git a/apps/api/src/chapter/repositories/adminChapter.repository.ts b/apps/api/src/chapter/repositories/adminChapter.repository.ts index c4c34660..06b20ddc 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, lessons, questionAnswerOptions, questions } from "src/storage import type { UpdateChapterBody } from "../schemas/chapter.schema"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import type { LessonItemWithContentSchema, QuestionSchema } from "src/lesson/lesson.schema"; +import type { AdminLessonWithContentSchema, QuestionSchema } from "src/lesson/lesson.schema"; import type * as schema from "src/storage/schema"; @Injectable() @@ -209,7 +209,7 @@ export class AdminChapterRepository { // // : await this.fileService.getFileUrl(lesson.imageUrl), // // }; - async getBetaChapterLessons(chapterId: UUIDType): Promise { + async getBetaChapterLessons(chapterId: UUIDType): Promise { return await this.db .select({ updatedAt: sql`${lessons.updatedAt}`, diff --git a/apps/api/src/courses/course.controller.ts b/apps/api/src/courses/course.controller.ts index a07cbfc9..10240823 100644 --- a/apps/api/src/courses/course.controller.ts +++ b/apps/api/src/courses/course.controller.ts @@ -171,6 +171,7 @@ export class CourseController { }) async getTeacherCourses( @Query("authorId") authorId: UUIDType, + // TODO: extract to const @Query("scope") scope: "all" | "enrolled" | "available" = "all", @Query("excludeCourseId") excludeCourseId: UUIDType, @CurrentUser("userId") currentUserId: UUIDType, diff --git a/apps/api/src/courses/course.service.ts b/apps/api/src/courses/course.service.ts index c8edce99..6ecd1182 100644 --- a/apps/api/src/courses/course.service.ts +++ b/apps/api/src/courses/course.service.ts @@ -20,7 +20,6 @@ import { sql, } from "drizzle-orm"; -import { LESSON_TYPE } from "src/chapter/chapter.type"; import { AdminChapterRepository } from "src/chapter/repositories/adminChapter.repository"; import { DatabasePg } from "src/common"; import { addPagination, DEFAULT_PAGE_SIZE } from "src/common/pagination"; @@ -58,7 +57,7 @@ import type { CommonShowCourse } from "./schemas/showCourseCommon.schema"; import type { UpdateCourseBody } from "./schemas/updateCourse.schema"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type { Pagination, UUIDType } from "src/common"; -import type { LessonItemWithContentSchema } from "src/lesson/lesson.schema"; +import type { AdminLessonWithContentSchema } from "src/lesson/lesson.schema"; import type * as schema from "src/storage/schema"; import type { ProgressStatus } from "src/utils/types/progress.type"; @@ -84,6 +83,7 @@ export class CourseService { currentUserRole, } = query; + // TODO: repair it // const { sortOrder, sortedField } = getSortOptions(sort); const sortOrder = asc; const sortedField = CourseSortFields.title; @@ -261,15 +261,15 @@ export class CourseService { priceInCents: courses.priceInCents, currency: courses.currency, hasFreeChapters: sql` - EXISTS ( - SELECT 1 - FROM ${chapters} - WHERE ${chapters.courseId} = ${courses.id} - AND ${chapters.isFreemium} = TRUE - )`, + EXISTS ( + SELECT 1 + FROM ${chapters} + WHERE ${chapters.courseId} = ${courses.id} + AND ${chapters.isFreemium} = TRUE + ) + `, }) .from(courses) - .leftJoin(studentCourses, eq(studentCourses.courseId, courses.id)) .leftJoin(categories, eq(courses.categoryId, categories.id)) .leftJoin(users, eq(courses.authorId, users.id)) .leftJoin(coursesSummaryStats, eq(courses.id, coursesSummaryStats.courseId)) @@ -283,11 +283,9 @@ export class CourseService { users.firstName, users.lastName, users.email, - studentCourses.studentId, categories.title, coursesSummaryStats.freePurchasedCount, coursesSummaryStats.paidPurchasedCount, - studentCourses.finishedChapterCount, ) .orderBy(sortOrder(this.getColumnToSortBy(sortedField as CourseSortField))); @@ -297,7 +295,6 @@ export class CourseService { const [{ totalItems }] = await trx .select({ totalItems: countDistinct(courses.id) }) .from(courses) - .leftJoin(studentCourses, eq(studentCourses.courseId, courses.id)) .leftJoin(categories, eq(courses.categoryId, categories.id)) .leftJoin(users, eq(courses.authorId, users.id)) .where(and(...conditions)); @@ -369,7 +366,7 @@ export class CourseService { (SELECT COUNT(*) FROM ${lessons} WHERE ${lessons.chapterId} = ${chapters.id} - AND ${lessons.type} = ${LESSON_TYPE.quiz.key})::INTEGER`, + AND ${lessons.type} = ${LESSON_TYPES.QUIZ})::INTEGER`, completedLessonCount: sql`COALESCE(${studentChapterProgress.completedLessonCount}, 0)`, chapterProgress: sql` CASE @@ -398,7 +395,7 @@ export class CourseService { ELSE 'not_started' END AS status, CASE - WHEN ${lessons.type} = ${LESSON_TYPES.quiz} THEN COUNT(${questions.id}) + WHEN ${lessons.type} = ${LESSON_TYPES.QUIZ} THEN COUNT(${questions.id}) ELSE NULL END AS "quizQuestionCount" FROM ${lessons} @@ -493,7 +490,7 @@ export class CourseService { const updatedCourseLessonList = await Promise.all( courseChapterList?.map(async (chapter) => { - const lessons: LessonItemWithContentSchema[] = + const lessons: AdminLessonWithContentSchema[] = await this.adminChapterRepository.getBetaChapterLessons(chapter.id); const lessonsWithSignedUrls = await this.addS3SignedUrlsToLessonsAndQuestions(lessons); @@ -590,9 +587,9 @@ export class CourseService { excludeCourseId, ); - if (availableCourseIds.length) { - conditions.push(inArray(courses.id, availableCourseIds)); - } + if (!availableCourseIds.length) return []; + + conditions.push(inArray(courses.id, availableCourseIds)); } return this.db @@ -620,7 +617,10 @@ export class CourseService { )`, }) .from(courses) - .leftJoin(studentCourses, eq(studentCourses.courseId, courses.id)) + .leftJoin( + studentCourses, + and(eq(studentCourses.courseId, courses.id), eq(studentCourses.studentId, currentUserId)), + ) .leftJoin(categories, eq(courses.categoryId, categories.id)) .leftJoin(users, eq(courses.authorId, users.id)) .where(and(...conditions)) @@ -823,7 +823,7 @@ export class CourseService { studentId, lessonId: lesson.id, completedQuestionCount: 0, - quizScore: lesson.type === LESSON_TYPES.quiz ? 0 : null, + quizScore: lesson.type === LESSON_TYPES.QUIZ ? 0 : null, completedAt: null, })), ); @@ -928,13 +928,13 @@ export class CourseService { ); } - private async addS3SignedUrlsToLessonsAndQuestions(lessons: LessonItemWithContentSchema[]) { + private async addS3SignedUrlsToLessonsAndQuestions(lessons: AdminLessonWithContentSchema[]) { return await Promise.all( lessons.map(async (lesson) => { const updatedLesson = { ...lesson }; if ( lesson.fileS3Key && - (lesson.type === LESSON_TYPES.video || lesson.type === LESSON_TYPES.presentation) + (lesson.type === LESSON_TYPES.VIDEO || lesson.type === LESSON_TYPES.PRESENTATION) ) { if (!lesson.fileS3Key.startsWith("https://")) { try { diff --git a/apps/api/src/lesson/adminLesson.service.ts b/apps/api/src/lesson/adminLesson.service.ts deleted file mode 100644 index a51410a6..00000000 --- a/apps/api/src/lesson/adminLesson.service.ts +++ /dev/null @@ -1,704 +0,0 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from "@nestjs/common"; -import { eq, gte, inArray, lte, sql } from "drizzle-orm"; - -import { DatabasePg } from "src/common"; -import { lessons, questionAnswerOptions, questions } from "src/storage/schema"; - -import { AdminLessonRepository } from "./adminLesson.repository"; -import { LessonRepository } from "./lesson.repository"; -import { LESSON_TYPES } from "./lesson.type"; - -import type { - CreateLessonBody, - CreateQuizLessonBody, - UpdateLessonBody, - UpdateQuizLessonBody, -} from "./lesson.schema"; -import type { UUIDType } from "src/common"; - -@Injectable() -export class AdminLessonService { - constructor( - @Inject("DB") private readonly db: DatabasePg, - private adminLessonRepository: AdminLessonRepository, - private lessonRepository: LessonRepository, - ) {} - - async createLessonForChapter(data: CreateLessonBody, authorId: UUIDType) { - if ( - (data.type === LESSON_TYPES.presentation || data.type === LESSON_TYPES.video) && - (!data.fileS3Key || !data.fileType) - ) { - throw new BadRequestException("File is required for video and presentation lessons"); - } - - const maxDisplayOrder = await this.adminLessonRepository.getMaxDisplayOrder(data.chapterId); - - const lesson = await this.adminLessonRepository.createLessonForChapter( - { ...data, displayOrder: maxDisplayOrder + 1 }, - authorId, - ); - return lesson.id; - } - - async createQuizLesson(data: CreateQuizLessonBody, authorId: UUIDType) { - const maxDisplayOrder = await this.adminLessonRepository.getMaxDisplayOrder(data.chapterId); - - const lesson = await this.createQuizLessonWithQuestionsAndOptions( - data, - authorId, - maxDisplayOrder + 1, - ); - return lesson?.id; - } - - async updateQuizLesson(id: UUIDType, data: UpdateQuizLessonBody, authorId: UUIDType) { - const lesson = await this.lessonRepository.getLesson(id); - - if (!lesson) { - throw new NotFoundException("Lesson not found"); - } - - const updatedLessonId = await this.updateQuizLessonWithQuestionsAndOptions(id, data, authorId); - return updatedLessonId; - } - - async updateLesson(id: string, data: UpdateLessonBody) { - const lesson = await this.lessonRepository.getLesson(id); - - if (!lesson) { - throw new NotFoundException("Lesson not found"); - } - - if ( - (data.type === LESSON_TYPES.presentation || data.type === LESSON_TYPES.video) && - (!data.fileS3Key || !data.fileType) - ) { - throw new BadRequestException("File is required for video and presentation lessons"); - } - - const updatedLesson = await this.adminLessonRepository.updateLesson(id, data); - return updatedLesson.id; - } - - async removeLesson(lessonId: UUIDType) { - const [lesson] = await this.adminLessonRepository.getLesson(lessonId); - - if (!lesson) { - throw new NotFoundException("Lesson not found"); - } - - await this.db.transaction(async (trx) => { - await this.adminLessonRepository.removeLesson(lessonId, trx); - await this.adminLessonRepository.updateLessonDisplayOrder(lesson.chapterId, trx); - }); - } - - async updateLessonDisplayOrder(lessonObject: { - lessonId: UUIDType; - displayOrder: number; - }): Promise { - const [lessonToUpdate] = await this.adminLessonRepository.getLesson(lessonObject.lessonId); - - const oldDisplayOrder = lessonToUpdate.displayOrder; - if (!lessonToUpdate || oldDisplayOrder === null) { - throw new NotFoundException("Lesson not found"); - } - - const newDisplayOrder = lessonObject.displayOrder; - - await this.db.transaction(async (trx) => { - await trx - .update(lessons) - .set({ - displayOrder: sql`CASE - WHEN ${eq(lessons.id, lessonToUpdate.id)} - THEN ${newDisplayOrder} - WHEN ${newDisplayOrder < oldDisplayOrder} - AND ${gte(lessons.displayOrder, newDisplayOrder)} - AND ${lte(lessons.displayOrder, oldDisplayOrder)} - THEN ${lessons.displayOrder} + 1 - WHEN ${newDisplayOrder > oldDisplayOrder} - AND ${lte(lessons.displayOrder, newDisplayOrder)} - AND ${gte(lessons.displayOrder, oldDisplayOrder)} - THEN ${lessons.displayOrder} - 1 - ELSE ${lessons.displayOrder} - END - `, - }) - .where(eq(lessons.chapterId, lessonToUpdate.chapterId)); - }); - } - - async createQuizLessonWithQuestionsAndOptions( - data: CreateQuizLessonBody, - authorId: UUIDType, - displayOrder: number, - ) { - return await this.db.transaction(async (trx) => { - const lesson = await this.adminLessonRepository.createQuizLessonWithQuestionsAndOptions( - data, - displayOrder, - ); - - if (!data.questions) return; - - const questionsToInsert = data?.questions?.map((question) => ({ - lessonId: lesson.id, - authorId, - type: question.type, - description: question.description || null, - title: question.title, - photoS3Key: question.photoS3Key, - photoQuestionType: question.photoQuestionType || null, - })); - - const insertedQuestions = await trx.insert(questions).values(questionsToInsert).returning(); - - const optionsToInsert = insertedQuestions.flatMap( - (question, index) => - data.questions?.[index].options?.map((option) => ({ - questionId: question.id, - optionText: option.optionText, - isCorrect: option.isCorrect, - position: option.position, - })) || [], - ); - - if (optionsToInsert.length > 0) { - await trx.insert(questionAnswerOptions).values(optionsToInsert); - } - - return lesson; - }); - } - - async updateQuizLessonWithQuestionsAndOptions( - id: UUIDType, - data: UpdateQuizLessonBody, - authorId: UUIDType, - ) { - return await this.db.transaction(async (trx) => { - await this.adminLessonRepository.updateQuizLessonWithQuestionsAndOptions(id, data); - - const existingQuestions = await trx - .select({ id: questions.id }) - .from(questions) - .where(eq(questions.lessonId, id)); - - const existingQuestionIds = existingQuestions.map((question) => question.id); - - const inputQuestionIds = data.questions - ? data.questions.map((question) => question.id).filter(Boolean) - : []; - - const questionsToDelete = existingQuestionIds.filter( - (existingId) => !inputQuestionIds.includes(existingId), - ); - - if (questionsToDelete.length > 0) { - await trx.delete(questions).where(inArray(questions.id, questionsToDelete)); - await trx - .delete(questionAnswerOptions) - .where(inArray(questionAnswerOptions.questionId, questionsToDelete)); - } - - if (data.questions) { - for (const question of data.questions) { - const questionData = { - type: question.type, - description: question.description || null, - title: question.title, - photoS3Key: question.photoS3Key, - photoQuestionType: question.photoQuestionType || null, - }; - - const questionId = - question.id ?? - (( - await trx - .insert(questions) - .values({ - lessonId: id, - authorId, - ...questionData, - }) - .returning() - )[0].id as UUIDType); - - if (question.id) { - await trx.update(questions).set(questionData).where(eq(questions.id, questionId)); - } - - if (question.options) { - const existingOptions = await trx - .select({ id: questionAnswerOptions.id }) - .from(questionAnswerOptions) - .where(eq(questionAnswerOptions.questionId, questionId)); - - const existingOptionIds = existingOptions.map((option) => option.id); - const inputOptionIds = question.options.map((option) => option.id).filter(Boolean); - - const optionsToDelete = existingOptionIds.filter( - (existingId) => !inputOptionIds.includes(existingId), - ); - - if (optionsToDelete.length > 0) { - await trx - .delete(questionAnswerOptions) - .where(inArray(questionAnswerOptions.id, optionsToDelete)); - } - - for (const option of question.options) { - const optionData = { - optionText: option.optionText, - isCorrect: option.isCorrect, - position: option.position, - }; - - if (option.id) { - await trx - .update(questionAnswerOptions) - .set(optionData) - .where(eq(questionAnswerOptions.id, option.id)); - } else { - await trx.insert(questionAnswerOptions).values({ - questionId, - ...optionData, - }); - } - } - } - } - } - - return id; - }); - } - - // async getAllLessonItems(query: GetLessonItemsQuery = {}) { - // const { - // type, - // title, - // state, - // archived, - // sort = "title", - // page = 1, - // perPage = DEFAULT_PAGE_SIZE, - // } = query; - - // let questionItems: QuestionSelectType[] = []; - // let textItems: TextBlockSelectType[] = []; - // let fileItems: FileSelectType[] = []; - - // const questionConditions = [ - // ...(title ? [ilike(questions.questionBody, `%${title.toLowerCase()}%`)] : []), - // ...(state ? [eq(questions.state, state)] : []), - // ...(archived !== undefined ? [eq(questions.archived, archived)] : []), - // ]; - - // const textBlockConditions = [ - // ...(title ? [ilike(textBlocks.title, `%${title.toLowerCase()}%`)] : []), - // ...(state ? [eq(textBlocks.state, state)] : []), - // ...(archived !== undefined ? [eq(textBlocks.archived, archived)] : []), - // ]; - - // const fileConditions = [ - // ...(title ? [ilike(files.title, `%${title.toLowerCase()}%`)] : []), - // ...(state ? [eq(files.state, state)] : []), - // ...(archived !== undefined ? [eq(files.archived, archived)] : []), - // ]; - - // if (!type || type === "question") { - // questionItems = await this.adminLessonItemsRepository.getQuestions(questionConditions); - // } - // if (!type || type === "text_block") { - // textItems = await this.adminLessonItemsRepository.getTextBlocks(textBlockConditions); - // } - // if (!type || type === "file") { - // fileItems = await this.adminLessonItemsRepository.getFiles(fileConditions); - // } - - // const allItems = [ - // ...questionItems.map((item) => ({ - // ...item, - // itemType: "question" as const, - // })), - // ...textItems.map((item) => ({ - // ...item, - // itemType: "text_block" as const, - // })), - // ...fileItems.map((item) => ({ - // ...item, - // itemType: "file" as const, - // })), - // ]; - - // const sortedItems = sort - // ? allItems.sort((a, b) => { - // const aValue = this.getSortValue(a, sort.startsWith("-") ? sort.slice(1) : sort); - // const bValue = this.getSortValue(b, sort.startsWith("-") ? sort.slice(1) : sort); - - // const comparison = aValue > bValue ? 1 : -1; - // return sort.startsWith("-") ? -comparison : comparison; - // }) - // : allItems; - - // const start = (page - 1) * perPage; - // const paginatedItems = sortedItems.slice(start, start + perPage); - - // return { - // data: paginatedItems, - // pagination: { - // page, - // perPage, - // totalItems: sortedItems.length, - // }, - // }; - // } - - // async getAvailableLessonItems() { - // const questionItems = await this.adminLessonItemsRepository.getQuestions([ - // eq(questions.state, "published"), - // eq(questions.archived, false), - // isNotNull(questions.id), - // ]); - // const textItems = await this.adminLessonItemsRepository.getTextBlocks([ - // eq(textBlocks.state, "published"), - // eq(textBlocks.archived, false), - // isNotNull(textBlocks.id), - // isNotNull(textBlocks.title), - // isNotNull(textBlocks.body), - // ]); - - // const fileItems = await this.adminLessonItemsRepository.getFiles([ - // eq(files.state, "published"), - // eq(files.archived, false), - // isNotNull(files.id), - // isNotNull(files.title), - // ]); - - // const allItems = [ - // ...questionItems.map((item) => ({ - // ...item, - // itemType: "question" as const, - // })), - // ...textItems.map((item) => ({ - // ...item, - // itemType: "text_block" as const, - // })), - // ...fileItems.map((item) => ({ ...item, itemType: "file" as const })), - // ]; - - // return allItems; - // } - - // async getLessonItemById(id: UUIDType) { - // const [textBlock, question, file] = await Promise.all([ - // this.adminLessonItemsRepository.getTextBlocks([eq(textBlocks.id, id)]), - // this.adminLessonItemsRepository.getQuestions([eq(questions.id, id)]), - // this.adminLessonItemsRepository.getFiles([eq(files.id, id)]), - // ]); - - // if (textBlock.length > 0) { - // return { ...textBlock[0], itemType: "text_block" as const }; - // } - - // if (question.length > 0) { - // return { ...question[0], itemType: "question" as const }; - // } - - // if (file.length > 0) { - // return { ...file[0], itemType: "file" as const }; - // } - - // throw new NotFoundException("Lesson item not found"); - // } - - // async assignItemsToLesson(lessonId: stritems: LessonItemToAdd[]): Promise { - // const lesson = await this.adminChapterRepository.getLessonById(lessonId); - - // if (!lesson) { - // throw new NotFoundException("Lesson not found"); - // } - - // if (lesson.type == LESSON_TYPE.quiz.key) { - // const lessonStudentAnswers = await this.adminLessonItemsRepository.getLessonStudentAnswers( - // lesson.id, - // ); - - // if (lessonStudentAnswers.length > 0) { - // throw new ConflictException("Lesson already answered, you can't add more items"); - // } - // } - - // await this.verifyItems(items); - - // await this.db.transaction(async (trx) => { - // await this.adminLessonRepository.addLessonItemToLesson(lessonId, items, trx); - // await this.adminChapterRepository.updateLessonItemsCount(lessonId, trx); - // }); - // } - - // async unassignItemsFromLesson(lessonId:ng, items: LessonItemToRemove[]): Promise { - // const lesson = await this.adminChapterRepository.getLessonById(lessonId); - - // if (!lesson) { - // throw new NotFoundException("Lesson not found"); - // } - - // if (lesson.type == LESSON_TYPE.quiz.key) { - // const lessonStudentAnswers = await this.adminLessonItemsRepository.getLessonStudentAnswers( - // lesson.id, - // ); - - // if (lessonStudentAnswers.length > 0) { - // throw new ConflictException("Lesson already answered, you can't add more items"); - // } - // } - - // await this.db.transaction(async (trx) => { - // await this.adminLessonRepository.removeLessonItemFromLesson(lessonId, items, trx); - // await this.adminChapterRepository.updateLessonItemsCount(lessonId, trx); - // }); - // } - - // async updateTextBlockItem(id: UUIDType, body: UpdateTextBlockBody) { - // const [existingTextBlock] = await this.adminLessonItemsRepository.getTextBlocks([ - // eq(textBlocks.id, id), - // ]); - - // if (!existingTextBlock) { - // throw new NotFoundException("Text block not found"); - // } - - // return await this.adminLessonItemsRepository.updateTextBlockItem(id, body); - // } - - // async updateQuestionItem(id: UUIDType, body: UpdateQuestionBody) { - // const [question] = await this.adminLessonItemsRepository.getQuestions([eq(questions.id, id)]); - - // if (!question) throw new NotFoundException("Question not found"); - - // // TODO: this check may need to be changed - // const questionStudentAnswers = await this.adminLessonItemsRepository.getQuestionStudentAnswers( - // question.id, - // ); - - // if (questionStudentAnswers.length > 0) { - // throw new ConflictException("Question already answered"); - // } - - // return await this.adminLessonItemsRepository.updateQuestionItem(id, body); - // } - - // async updateFileItem(id: UUIDType, body: UpdateFileBody) { - // const [file] = await this.adminLessonItemsRepository.getFiles([eq(files.id, id)]); - - // if (!file) throw new NotFoundException("File not found"); - - // return await this.adminLessonItemsRepository.updateFileItem(id, body); - // } - - // async createTextBlock(content: TextBlockInsertType, userId: UUIDType) { - // const textBlock = await this.adminLessonItemsRepository.createTextBlock(content, userId); - - // if (!textBlock) throw new NotFoundException("Text block not found"); - - // return textBlock; - // } - - // async createQuestion(content: QuestionInsertType, userId: UUIDType) { - // const question = await this.adminLessonItemsRepository.createQuestion(content, userId); - - // if (!question) throw new NotFoundException("Question not found"); - - // return question; - // } - - // async createTextBlockAndAssignToLesson(content: TextBlockWithLessonId, userId: UUIDType) { - // return await this.db.transaction(async (trx) => { - // const textBlock = await this.adminLessonItemsRepository.createBetaTextBlock(content, userId); - // const highestDisplayOrder = await this.adminLessonItemsRepository.getHighestDisplayOrder( - // content.lessonId, - // trx, - // ); - - // const newDisplayOrder = highestDisplayOrder + 1; - - // const items: LessonItemToAdd[] = [ - // { - // id: textBlock.id, - // type: "text_block", - // displayOrder: newDisplayOrder, - // }, - // ]; - - // await this.assignItemsToLesson(content.lessonId, items); - - // if (!textBlock) throw new NotFoundException("Text block not found"); - - // return textBlock; - // }); - // } - - // async createFileAndAssignToLesson(content: BetaFileLessonType, userId: UUIDType) { - // return await this.db.transaction(async (trx) => { - // const file = await this.adminLessonItemsRepository.createFile(content, userId); - - // if (!file) throw new NotFoundException("File not found"); - - // const highestDisplayOrder = await this.adminLessonItemsRepository.getHighestDisplayOrder( - // content.lessonId, - // trx, - // ); - - // const newDisplayOrder = highestDisplayOrder + 1; - - // const items: LessonItemToAdd[] = [ - // { - // id: file.id, - // type: "file", - // displayOrder: newDisplayOrder, - // }, - // ]; - - // await this.assignItemsToLesson(content.lessonId, items); - - // return file; - // }); - // } - - // async getQuestionAnswers(questionId: UUIDType) { - // return await this.adminLessonItemsRepository.getQuestionAnswers(questionId); - // } - - // async removeLesson(chapterId: string, lessonId: string) { - // const lessonItem = await this.adminLessonItemsRepository.getLessonItem(lessonId); - - // const result = await this.adminLessonItemsRepository.removeLesson( - // lessonId, - // lessonItem.lessonItemType as LessonItemTypes, - // ); - - // if (result.length === 0) { - // throw new NotFoundException("Lesson not found in this course"); - // } - - // await this.adminLessonItemsRepository.updateLessonItemDisplayOrder(chapterId, lessonId); - // } - - // async updateFreemiumStatssonId: string, isFreemium: boolean) { - // await this.adminChapterRepository.updateFreemiumStatus(lessonId, isFreemium); - // } - - // async upsertQuestionOptions( - // questionId: UUIDType, - // options: Array< - // Partial<{ - // id: string; - // optionText: string; - // isCorrect: boolean; - // position: number; - // }> - // >, - // ) { - // await this.db.transaction(async (trx) => { - // const questionStudentAnswers = - // await this.adminLessonItemsRepository.getQuestionStudentAnswers(questionId, trx); - - // if (questionStudentAnswers.length > 0) { - // throw new ConflictException("Question already answered"); - // } - - // const existingOptions = await this.adminLessonItemsRepository.getQuestionAnswers( - // questionId, - // trx, - // ); - - // const existingIds = new Set(existingOptions.map((opt) => opt.id)); - - // const optionsWithIds = options.filter((opt) => opt.id); - // const idsToKeep = new Set(optionsWithIds.map((opt) => opt.id)); - // const idsToDelete = [...existingIds].filter((id) => !idsToKeep.has(id)); - - // if (idsToDelete.length > 0) { - // await this.adminLessonItemsRepository.removeQuestionAnswerOptions( - // questionId, - // idsToDelete, - // trx, - // ); - // } - - // for (const option of options) { - // if ( - // option.optionText === undefined || - // option.isCorrect === undefined || - // option.position === undefined - // ) { - // continue; - // } - - // await this.adminLessonItemsRepository.upsertQuestionAnswerOptions( - // questionId, - // { - // id: option.id, - // optionText: option.optionText, - // isCorrect: option.isCorrect, - // position: option.position, - // }, - // trx, - // ); - // } - // }); - // } - - // async createFile(content: FileInsertType, userId: UUIDType) { - // const file = await this.adminLessonItemsRepository.createFile(content, userId); - - // if (!file) throw new NotFoundException("File not found"); - - // return file; - // } - - // private async verifyItems( - // items: Array<{ id: string; type: LessonItemType; displayOrder: number }>, - // ): Promise { - // for (const item of items) { - // let result: any[] = []; - // switch (item.type) { - // case "text_block": - // result = await this.adminLessonItemsRepository.getTextBlocks([ - // eq(textBlocks.id, item.id), - // ]); - // break; - // case "file": - // case "video": - // result = await this.adminLessonItemsRepository.getFiles([eq(files.id, item.id)]); - // break; - // case "question": - // result = await this.adminLessonItemsRepository.getQuestions([eq(questions.id, item.id)]); - // break; - // } - - // if (result.length === 0) { - // throw new BadRequestException(`Element ${item.id} type of ${item.type} not found`); - // } - // } - // } - - // private getSortValue(item: SingleLessonItemResponse, field: string) { - // if (field === "title") { - // if (item.itemType === "question") { - // return item.questionBody; - // } - // return item.title; - // } - - // if (field === "createdAt") return item.createdAt; - // if (field === "state") return item.state; - // if (field === "archived") return item.archived; - - // return ""; - // } -} diff --git a/apps/api/src/lesson/lesson.controller.ts b/apps/api/src/lesson/lesson.controller.ts index 0a7d5b7d..046a44e2 100644 --- a/apps/api/src/lesson/lesson.controller.ts +++ b/apps/api/src/lesson/lesson.controller.ts @@ -1,4 +1,14 @@ -import { Body, Controller, Delete, Patch, Post, Query, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from "@nestjs/common"; import { Type } from "@sinclair/typebox"; import { Validate } from "nestjs-typebox"; @@ -8,146 +18,38 @@ import { CurrentUser } from "src/common/decorators/user.decorator"; import { RolesGuard } from "src/common/guards/roles.guard"; import { USER_ROLES } from "src/user/schemas/userRoles"; -import { AdminLessonService } from "./adminLesson.service"; import { CreateLessonBody, createLessonSchema, CreateQuizLessonBody, createQuizLessonSchema, + lessonShowSchema, UpdateLessonBody, updateLessonSchema, UpdateQuizLessonBody, updateQuizLessonSchema, } from "./lesson.schema"; +import { AdminLessonService } from "./services/adminLesson.service"; +import { LessonService } from "./services/lesson.service"; -// import { -// BetaFileLessonType, -// betaFileSelectSchema, -// betaTextLessonSchema, -// BetaTextLessonType, -// type FileInsertType, -// fileUpdateSchema, -// type GetAllLessonItemsResponse, -// GetAllLessonItemsResponseSchema, -// type GetSingleLessonItemsResponse, -// GetSingleLessonItemsResponseSchema, -// type QuestionInsertType, -// questionUpdateSchema, -// type TextBlockInsertType, -// textBlockUpdateSchema, -// type UpdateFileBody, -// type UpdateQuestionBody, -// type UpdateTextBlockBody, -// } from "./schemas/lessonItem.schema"; -// import { -// type LessonsFilterSchema, -// sortLessonFieldsOptions, -// type SortLessonFieldsOptions, -// } from "./schemas/lessonQuery"; +import type { LessonShow } from "./lesson.schema"; @Controller("lesson") @UseGuards(RolesGuard) export class LessonController { constructor( - // private readonly chapterService: CService, - private readonly adminLessonsService: AdminLessonService, // private readonly adminLessonItemsService: AdminLessonItemsService, + private readonly adminLessonsService: AdminLessonService, + private readonly lessonService: LessonService, ) {} - // @Get() - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // response: paginatedResponse(allLessonsSchema), - // request: [ - // { type: "query", name: "title", schema: Type.Optional(Type.String()) }, - // { type: "query", name: "state", schema: Type.Optional(Type.String()) }, - // { type: "query", name: "sort", schema: sortLessonFieldsOptions }, - // { type: "query", name: "page", schema: Type.Number({ minimum: 1 }) }, - // { type: "query", name: "perPage", schema: Type.Number() }, - // { type: "query", name: "archived", schema: Type.Optional(Type.String()) }, - // ], - // }) - // async getAllLessons( - // @Query("title") title: string, - // @Query("state") state: string, - // @Query("sort") sort: SortLessonFieldsOptions, - // @Query("page") page: number, - // @Query("perPage") perPage: number, - // @Query("archived") archived: string, - // @CurrentUser("role") currentUserRole: UserRole, - // @CurrentUser("userId") currentUserId: UUIDType, - // ): Promise> { - // const filters: LessonsFilterSchema = { - // title, - // state, - // archived: archived === "true", - // }; - - // const query = { filters, sort, page, perPage, currentUserRole, currentUserId }; - - // return new PaginatedResponse(await this.adminLessonsService.getAllLessons(query)); - // } - - // @Get("available-lessons") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [{ type: "query", name: "courseId", schema: UUIDSchema, required: true }], - // response: baseResponse(Type.Array(lessonWithCountItems)), - // }) - // async getAvailableLessons( - // @Query("courseId") courseId: UUIDType, - // ): Promise>> { - // const availableLessons = await this.adminLessonsService.getAvailableLessons(courseId); - // return new BaseResponse(availableLessons); - // } - - // @Get("lesson") - // @Roles(...Object.values(USER_ROLES)) - // @Validate({ - // request: [ - // { type: "query", name: "id", schema: UUIDSchema, required: true }, - // { type: "query", name: "courseId", schema: UUIDSchema, required: true }, - // ], - // response: baseResponse(showLessonSchema), - // }) - // async getLesson( - // @Query("id") id: UUIDType, - // @Query("courseId") courseId: UUIDType, - // @CurrentUser("role") userRole: UserRole, - // @CurrentUser("userId") userId: UUIDType, - // ): Promise> { - // return new BaseResponse( - // await this.lessonsService.getLesson(id, courseId, userId, userRole === USER_ROLES.ADMIN), - // ); - // } - - // @Get("lesson/:id") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // response: baseResponse(showLessonSchema), - // }) - // async getLessonById(@Param("id") id: string): Promise> { - // return new BaseResponse(await this.adminLessonsService.getLessonWithItemsById(id)); - // } - - // @Post("create-lesson") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { - // type: "body", - // schema: createLessonSchema, - // }, - // ], - // response: baseResponse(Type.Object({ id: UUIDSchema, message: Type.String() })), - // }) - // async createLesson( - // @Body() createLessonBody: CreateLessonBody, - // @CurrentUser("userId") userId: UUIDType, - // ): Promise> { - // const { id } = await this.adminLessonsService.createLesson(createLessonBody, userId); - - // return new BaseResponse({ id, message: "Lesson created successfully" }); - // } + @Get(":id") + @Roles(...Object.values(USER_ROLES)) + @Validate({ + response: baseResponse(lessonShowSchema), + }) + async getLessonById(@Param("id") id: UUIDType): Promise> { + return new BaseResponse(await this.lessonService.getLessonById(id)); + } @Post("beta-create-lesson") @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) @@ -256,137 +158,21 @@ export class LessonController { }); } - // @Patch("lesson") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { - // type: "query", - // name: "id", - // schema: UUIDSchema, - // }, - // { - // type: "body", - // schema: updateLessonSchema, - // }, - // ], - // response: baseResponse(Type.Object({ message: Type.String() })), - // }) - // async updateLesson( - // @Query() id: UUIDType, - // @Body() body: UpdateLessonBody, - // ): Promise> { - // await this.adminLessonsService.updateLesson(id, body); - // return new BaseResponse({ message: "Text block updated successfully" }); - // } - - // @Delete(":courseId/:lessonId") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { type: "param", name: "courseId", schema: UUIDSchema }, - // { type: "param", name: "lessonId", schema: UUIDSchema }, - // ], - // response: baseResponse(Type.Object({ message: Type.String() })), - // }) - // async removeLessonFromCourse( - // @Param("courseId") courseId: string, - // @Param("lessonId") lessonId: string, - // ): Promise> { - // await this.adminLessonsService.removeLessonFromCourse(courseId, lessonId); - // return new BaseResponse({ - // message: "Lesson removed from course successfully", - // }); - // } - - // @Delete("chapter/:courseId/:chapterId") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { type: "param", name: "courseId", schema: UUIDSchema }, - // { type: "param", name: "chapterId", schema: UUIDSchema }, - // ], - // response: baseResponse(Type.Object({ message: Type.String() })), - // }) - // async removeChapter( - // @Query("courseId") courseId: UUIDType, - // @Query("chapterId") chapterId: UUIDType, - // ): Promise> { - // await this.adminLessonsService.removeChapter(courseId, chapterId); - // return new BaseResponse({ - // message: "Lesson removed from course successfully", - // }); - // } - - // @Delete("lesson/:chapterId/:lessonId") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { type: "param", name: "chapterId", schema: UUIDSchema }, - // { type: "param", name: "lessonId", schema: UUIDSchema }, - // ], - // response: baseResponse(Type.Object({ message: Type.String() })), - // }) - // async removeLesson( - // @Param("chapterId") chapterId: string, - // @Param("lessonId") lessonId: string, - // ): Promise> { - // await this.adminLessonItemsService.removeLesson(chapterId, lessonId); - // return new BaseResponse({ - // message: "Lesson removed from course successfully", - // }); - // } - - // @Patch("course-lesson") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { - // type: "body", - // schema: Type.Object({ - // courseId: UUIDSchema, - // lessonId: UUIDSchema, - // isFree: Type.Boolean(), - // }), - // }, - // ], - // response: baseResponse(Type.Object({ isFree: Type.Boolean(), message: Type.String() })), - // }) - // async toggleLessonAsFree( - // @Body() body: { courseId: string; lessonId: string; isFree: boolean }, - // ): Promise> { - // const [toggledLesson] = await this.adminLessonsService.toggleLessonAsFree( - // body.courseId, - // body.lessonId, - // body.isFree, - // ); - // return new BaseResponse({ - // isFree: toggledLesson.isFree, - // message: body.isFree - // ? "Lesson toggled as free successfully" - // : "Lesson toggled as not free successfully", - // }); - // } - - // @Post("evaluation-quiz") - // @Roles(USER_ROLES.STUDENT) - // @Validate({ - // request: [ - // { type: "query", name: "courseId", schema: UUIDSchema }, - // { type: "query", name: "lessonId", schema: UUIDSchema }, - // ], - // response: baseResponse(Type.Object({ message: Type.String() })), - // }) - // async evaluationQuiz( - // @Query("courseId") courseId: string, - // @Query("lessonId") lessonId: string, - // @CurrentUser("userId") currentUserId: UUIDType, - // ): Promise> { - // await this.lessonsService.evaluationQuiz(courseId, lessonId, currentUserId); - // return new BaseResponse({ - // message: "Evaluation quiz successfully", - // }); - // } + // @Post("evaluation-quiz") + // @Roles(USER_ROLES.STUDENT) + // @Validate({ + // request: [{ type: "query", name: "lessonId", schema: UUIDSchema, required: true }], + // response: baseResponse(Type.Object({ message: Type.String() })), + // }) + // async evaluationQuiz( + // @Query("lessonId") lessonId: string, + // @CurrentUser("userId") currentUserId: UUIDType, + // ): Promise> { + // await this.lessonService.evaluationQuiz(lessonId, currentUserId); + // return new BaseResponse({ + // message: "Evaluation quiz successfully", + // }); + // } // @Delete("clear-quiz-progress") // @Roles(USER_ROLES.STUDENT) @@ -413,463 +199,6 @@ export class LessonController { // }); // } - // @Get("lesson-items") - // @Roles(USER_ROLES.ADMIN, USER_ROLES.TEACHER) - // @Validate({ - // request: [ - // { - // type: "query", - // name: "type", - // schema: Type.Union([ - // Type.Literal("text_block"), - // Type.Literal("question"), - // Type.Literal("file"), - // ]), - // }, - // { type: "query", name: "title", schema: Type.String() }, - // { type: "query", name: "state", schema: Type.String() }, - // { type: "query", name: "archived", schema: Type.String() }, - // { type: "query", name: "sort", schema: Type.String() }, - // { type: "query", name: "page", schema: Type.Number({ minimum: 1 }) }, - // { type: "query", name: "perPage", schema: Type.Number() }, - // ], - // response: paginatedResponse(GetAllLessonItemsResponseSchema), - // }) - // async getAllLessonItems( - // @Query("type") type: "text_block" | "question" | "file", - // @Query("title") title: string, - // @Query("state") state: string, - // @Query("archived") archived: string, - // @Query("sort") sort: string, - // @Query("page") page: number, - // @Query("perPage") perPage: number, - // ): Promise> { - // const query = { - // type, - // title, - // state, - // archived: archived === "true", - // sort, - // page, - // perPage, - // }; - - // const allLessonItems = await this.adminLessonItemsService.getAllLessonItems(query); - // return new PaginatedResponse(allLessonItems); - // } - - // @Get("available-lesson-items") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { - // type: "query", - // name: "type", - // schema: Type.Union([ - // Type.Literal("text_block"), - // Type.Literal("question"), - // Type.Literal("file"), - // ]), - // }, - // ], - // response: baseResponse(GetAllLessonItemsResponseSchema), - // }) - // async getAvailableLessonItems(): Promise> { - // const availableLessonItems = await this.adminLessonItemsService.getAvailableLessonItems(); - // return new BaseResponse(availableLessonItems); - // } - - // @Get("lesson-items/:id") - // @Roles(USER_ROLES.ADMIN, USER_ROLES.TEACHER) - // @Validate({ - // response: baseResponse(GetSingleLessonItemsResponseSchema), - // }) - // async getLessonItemById( - // @Param("id") id: string, - // ): Promise> { - // const lessonItem = await this.adminLessonItemsService.getLessonItemById(id); - // return new BaseResponse(lessonItem); - // } - - // @Post(":lessonId/assign-items") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { type: "param", name: "lessonId", schema: UUIDSchema }, - // { - // type: "body", - // schema: Type.Object({ - // items: Type.Array( - // Type.Object({ - // id: UUIDSchema, - // type: Type.Union([ - // Type.Literal("text_block"), - // Type.Literal("file"), - // Type.Literal("question"), - // ]), - // }), - // ), - // }), - // }, - // ], - // response: baseResponse(Type.Object({ message: Type.String() })), - // }) - // async assignItemsToLesson( - // @Param("lessonId") lessonId: string, - // @Body() - // body: { - // items: Array<{ - // id: string; - // type: "text_block" | "file" | "question"; - // displayOrder: number; - // }>; - // }, - // ): Promise> { - // await this.adminLessonItemsService.assignItemsToLesson(lessonId, body.items); - // return new BaseResponse({ - // message: "Successfully assigned items to lesson", - // }); - // } - - // @Post(":lessonId/unassign-items") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { type: "param", name: "lessonId", schema: UUIDSchema }, - // { - // type: "body", - // schema: Type.Object({ - // items: Type.Array( - // Type.Object({ - // id: UUIDSchema, - // type: Type.Union([ - // Type.Literal("text_block"), - // Type.Literal("file"), - // Type.Literal("question"), - // ]), - // }), - // ), - // }), - // }, - // ], - // response: baseResponse(Type.Object({ message: Type.String() })), - // }) - // async unassignItemsFromLesson( - // @Param("lessonId") lessonId: string, - // @Body() - // body: { - // items: Array<{ id: string; type: "text_block" | "file" | "question" }>; - // }, - // ): Promise> { - // await this.adminLessonItemsService.unassignItemsFromLesson(lessonId, body.items); - // return new BaseResponse({ - // message: "Successfully unassigned items from lesson", - // }); - // } - - // @Patch("text-block-item") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { - // type: "query", - // name: "id", - // schema: UUIDSchema, - // }, - // { - // type: "body", - // schema: textBlockUpdateSchema, - // }, - // ], - // response: baseResponse(Type.Object({ message: Type.String() })), - // }) - // async updateTextBlockItem( - // @Query() id: UUIDType, - // @Body() body: UpdateTextBlockBody, - // ): Promise> { - // await this.adminLessonItemsService.updateTextBlockItem(id, body); - // return new BaseResponse({ message: "Text block updated successfully" }); - // } - - // @Patch("question-item") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { - // type: "query", - // name: "id", - // schema: UUIDSchema, - // }, - // { - // type: "body", - // schema: questionUpdateSchema, - // }, - // ], - // response: baseResponse(Type.Object({ message: Type.String() })), - // }) - // async updateQuestionItem( - // @Query() id: UUIDType, - // @Body() body: UpdateQuestionBody, - // ): Promise> { - // await this.adminLessonItemsService.updateQuestionItem(id, body); - // return new BaseResponse({ message: "Question updated successfully" }); - // } - - // @Patch("file-item") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { - // type: "query", - // name: "id", - // schema: UUIDSchema, - // }, - // { - // type: "body", - // schema: fileUpdateSchema, - // }, - // ], - // response: baseResponse(Type.Object({ message: Type.String() })), - // }) - // async updateFileItem( - // @Query() id: UUIDType, - // @Body() body: UpdateFileBody, - // ): Promise> { - // await this.adminLessonItemsService.updateFileItem(id, body); - - // return new BaseResponse({ message: "File updated successfully" }); - // } - - // @Post("create-text-block") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { - // type: "body", - // schema: Type.Object({ - // title: Type.String(), - // body: Type.String(), - // state: Type.String(), - // authorId: UUIDSchema, - // }), - // }, - // ], - // response: baseResponse(Type.Object({ id: UUIDSchema, message: Type.String() })), - // }) - // async createTextBlock( - // @Body() body: TextBlockInsertType, - // @CurrentUser("userId") userId: UUIDType, - // ): Promise> { - // const { id } = await this.adminLessonItemsService.createTextBlock(body, userId); - - // return new BaseResponse({ id, message: "Text block created successfully" }); - // } - - // @Post("create-beta-text-block") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { - // type: "body", - // schema: betaTextLessonSchema, - // }, - // ], - // response: baseResponse(Type.Object({ id: UUIDSchema, message: Type.String() })), - // }) - // async createBetaTextBlock( - // @Body() body: BetaTextLessonType, - // @CurrentUser("userId") userId: UUIDType, - // ): Promise> { - // const { id } = await this.adminLessonItemsService.createTextBlockAndAssignToLesson( - // body, - // userId, - // ); - - // return new BaseResponse({ id, message: "Text block created successfully" }); - // } - - // @Post("create-question") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { - // type: "body", - // schema: Type.Object({ - // questionType: Type.String(), - // questionBody: Type.String(), - // state: Type.String(), - // authorId: UUIDSchema, - // solutionExplanation: Type.Optional(Type.String()), - // }), - // }, - // ], - // response: baseResponse(Type.Object({ message: Type.String(), questionId: UUIDSchema })), - // }) - // async createQuestion( - // @Body() - // body: QuestionInsertType, - // @CurrentUser("userId") userId: UUIDType, - // ): Promise> { - // const { id } = await this.adminLessonItemsService.createQuestion(body, userId); - - // return new BaseResponse({ - // questionId: id, - // message: "Question created successfully", - // }); - // } - - // @Get("question-options") - // @Roles(USER_ROLES.ADMIN, USER_ROLES.TEACHER) - // @Validate({ - // request: [ - // { - // type: "query", - // name: "questionId", - // schema: UUIDSchema, - // }, - // ], - // response: baseResponse( - // Type.Array( - // Type.Object({ - // id: UUIDSchema, - // optionText: Type.String(), - // isCorrect: Type.Boolean(), - // position: Type.Union([Type.Number(), Type.Null()]), - // }), - // ), - // ), - // }) - // async getQuestionAnswers(@Query("questionId") questionId: string): Promise< - // BaseResponse< - // { - // id: string; - // optionText: string; - // isCorrect: boolean; - // position: number | null; - // }[] - // > - // > { - // const options = await this.adminLessonItemsService.getQuestionAnswers(questionId); - // return new BaseResponse(options); - // } - - // @Patch("question-options") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { - // type: "query", - // name: "questionId", - // schema: UUIDSchema, - // }, - // { - // type: "body", - // schema: Type.Array( - // Type.Partial( - // Type.Object({ - // id: UUIDSchema, - // optionText: Type.String(), - // isCorrect: Type.Boolean(), - // position: Type.Number(), - // }), - // ), - // ), - // }, - // ], - // response: baseResponse(Type.Object({ message: Type.String() })), - // }) - // async upsertQuestionOptions( - // @Query("questionId") questionId: string, - // @Body() - // options: Array< - // Partial<{ - // id: string; - // optionText: string; - // isCorrect: boolean; - // position: number; - // }> - // >, - // ): Promise> { - // await this.adminLessonItemsService.upsertQuestionOptions(questionId, options); - // return new BaseResponse({ - // message: "Question options updated successfully", - // }); - // } - - // @Patch("course-lesson/freemium-status") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { - // type: "query", - // name: "lessonId", - // schema: UUIDSchema, - // }, - // { - // type: "body", - // schema: Type.Object({ - // isFreemium: Type.Boolean(), - // }), - // }, - // ], - // response: baseResponse(Type.Object({ message: Type.String() })), - // }) - // async updateLessonFreemiumStatus( - // @Query("lessonId") lessonId: string, - // @Body() body: { isFreemium: boolean }, - // ): Promise> { - // await this.adminLessonItemsService.updateFreemiumStatus(lessonId, body.isFreemium); - // return new BaseResponse({ - // message: "Course lesson free status updated successfully", - // }); - // } - - // @Post("create-file") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { - // type: "body", - // schema: Type.Object({ - // title: Type.String(), - // type: Type.String(), - // url: Type.String(), - // state: Type.String(), - // authorId: UUIDSchema, - // }), - // }, - // ], - // response: baseResponse(Type.Object({ id: UUIDSchema, message: Type.String() })), - // }) - // async createFile( - // @Body() body: FileInsertType, - // @CurrentUser("userId") userId: UUIDType, - // ): Promise> { - // const { id } = await this.adminLessonItemsService.createFile(body, userId); - - // return new BaseResponse({ id, message: "File created successfully" }); - // } - - // @Post("beta-create-file") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) - // @Validate({ - // request: [ - // { - // type: "body", - // schema: betaFileSelectSchema, - // }, - // ], - // response: baseResponse(Type.Object({ id: UUIDSchema, message: Type.String() })), - // }) - // async betaCreateFile( - // @Body() body: BetaFileLessonType, - // @CurrentUser("userId") userId: UUIDType, - // ): Promise> { - // const { id } = await this.adminLessonItemsService.createFileAndAssignToLesson(body, userId); - - // return new BaseResponse({ id, message: "File created successfully" }); - // } - @Patch("lesson-display-order") @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) @Validate({ diff --git a/apps/api/src/lesson/lesson.module.ts b/apps/api/src/lesson/lesson.module.ts index 4d8cc03c..126b294b 100644 --- a/apps/api/src/lesson/lesson.module.ts +++ b/apps/api/src/lesson/lesson.module.ts @@ -2,15 +2,16 @@ import { Module } from "@nestjs/common"; import { FileModule } from "src/file/files.module"; -import { AdminLessonRepository } from "./adminLesson.repository"; -import { AdminLessonService } from "./adminLesson.service"; import { LessonController } from "./lesson.controller"; -import { LessonRepository } from "./lesson.repository"; +import { AdminLessonRepository } from "./repositories/adminLesson.repository"; +import { LessonRepository } from "./repositories/lesson.repository"; +import { AdminLessonService } from "./services/adminLesson.service"; +import { LessonService } from "./services/lesson.service"; @Module({ imports: [FileModule], controllers: [LessonController], - providers: [LessonRepository, AdminLessonService, AdminLessonRepository], + providers: [LessonRepository, AdminLessonService, AdminLessonRepository, LessonService], exports: [AdminLessonService, AdminLessonRepository, LessonRepository], }) export class LessonModule {} diff --git a/apps/api/src/lesson/lesson.repository.ts b/apps/api/src/lesson/lesson.repository.ts deleted file mode 100644 index f549f6a8..00000000 --- a/apps/api/src/lesson/lesson.repository.ts +++ /dev/null @@ -1,778 +0,0 @@ -import { Inject, Injectable } from "@nestjs/common"; -import { and, eq, sql } from "drizzle-orm"; - -import { DatabasePg, type UUIDType } from "src/common"; -import { chapters, lessons, questions, studentLessonProgress } from "src/storage/schema"; - -// import { STATES } from "src/common/states"; -// import { QUESTION_TYPE } from "src/questions/schema/questions.types"; -// import { -// chapters, -// courses, -// lessons, -// questionAnswerOptions, -// questions, -// studentChapterProgress, -// studentCourses, -// studentQuestionAnswers, -// } from "src/storage/schema"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import type { QuestionBody } from "src/lesson/lesson.schema"; -import type * as schema from "src/storage/schema"; - -@Injectable() -export class LessonRepository { - constructor(@Inject("DB") private readonly db: DatabasePg) {} - - async getLesson(id: UUIDType) { - const [lesson] = await this.db.select().from(lessons).where(eq(lessons.id, id)); - return lesson; - } - - async getLessonsByChapterId(chapterId: UUIDType) { - return this.db - .select({ - id: lessons.id, - title: lessons.title, - type: lessons.type, - description: sql`${lessons.description}`, - fileS3Key: sql`${lessons.fileS3Key}`, - fileType: sql`${lessons.fileType}`, - displayOrder: sql`${lessons.displayOrder}`, - questions: sql` - COALESCE( - ( - SELECT json_agg(questions_data) - FROM ( - SELECT - ${questions.id} AS id, - ${questions.title} AS title, - ${questions.description} AS description, - ${questions.type} AS type, - ${questions.photoQuestionType} AS photoQuestionType, - ${questions.photoS3Key} AS photoS3Key, - ${questions.solutionExplanation} AS solutionExplanation, - -- TODO: add display order - FROM ${questions} - WHERE ${lessons.id} = ${questions.lessonId} - -- ORDER BY ${lessons.displayOrder} - ) AS questions_data - ), - '[]'::json - ) - `, - }) - .from(lessons) - .where(eq(lessons.chapterId, chapterId)) - .orderBy(lessons.displayOrder); - } - - // async getChapterForUser(id: UUIDType, userId: UUIDType) { - // const [lesson] = await this.db - // .select({ - // id: chapters.id, - // title: chapters.title, - // isFree: chapters.isFreemium, - // enrolled: sql`CASE WHEN ${studentCourses.id} IS NOT NULL THEN true ELSE false END`, - // itemsCount: chapters.lessonCount, - // }) - // .from(chapters) - // .leftJoin( - // studentChapterProgress, - // and( - // eq(studentChapterProgress.courseId, chapters.courseId), - // eq(studentChapterProgress.chapterId, chapters.id), - // eq(studentChapterProgress.studentId, userId), - // ), - // ) - // .leftJoin( - // studentCourses, - // and(eq(studentCourses.courseId, chapters.courseId), eq(studentCourses.studentId, userId)), - // ) - // .where(and(eq(chapters.id, id), eq(chapters.isPublished, true))); - - // return lesson; - // } - - // async getChapter(id: UUIDType) { - // const [lesson] = await this.db - // .select({ - // id: chapters.id, - // title: chapters.title, - // isFree: chapters.isFreemium, - // itemsCount: chapters.lessonCount, - // }) - // .from(chapters) - // .where(and(eq(chapters.id, id), eq(chapters.isPublished, true))); - - // return lesson; - // } - - // async getQuestionItems( - // lessonId: UUIDType, - // studentId: UUIDType, - // lessonType: string, - // lessonRated: boolean, - // ) { - // return await this.db - // .select({ - // lessonItemType: lessonItems.lessonItemType, - // lessonItemId: lessonItems.id, - // questionData: questions, - // displayOrder: lessonItems.displayOrder, - // passQuestion: sql` - // CASE - // WHEN ${lessonType} = ${LESSON_TYPE.quiz.key} AND ${lessonRated} THEN - // ${studentQuestionAnswers.isCorrect} - // ELSE null - // END - // `, - // }) - // .from(lessonItems) - // .leftJoin( - // questions, - // and( - // eq(lessonItems.lessonItemId, questions.id), - // eq(lessonItems.lessonItemType, "question"), - // eq(questions.state, STATES.published), - // ), - // ) - // .leftJoin( - // studentQuestionAnswers, - // and( - // eq(studentQuestionAnswers.questionId, questions.id), - // eq(studentQuestionAnswers.studentId, studentId), - // eq(studentQuestionAnswers.lessonId, lessonId), - // ), - // ) - // .where(eq(lessonItems.lessonId, lessonId)) - // .orderBy(lessonItems.displayOrder); - // } - - // async getLessonItems(lessonId: UUIDType, courseId: UUIDType) { - // return await this.db - // .select({ - // lessonItemType: lessonItems.lessonItemType, - // lessonItemId: lessonItems.id, - // questionData: questions, - // textBlockData: textBlocks, - // fileData: files, - // displayOrder: lessonItems.displayOrder, - // isCompleted: sql`CASE WHEN ${studentCompletedLessonItems.id} IS NOT NULL THEN true ELSE false END`, - // }) - // .from(lessonItems) - // .leftJoin( - // questions, - // and( - // eq(lessonItems.lessonItemId, questions.id), - // eq(lessonItems.lessonItemType, "question"), - // eq(questions.state, STATES.published), - // ), - // ) - // .leftJoin( - // textBlocks, - // and( - // eq(lessonItems.lessonItemId, textBlocks.id), - // eq(lessonItems.lessonItemType, "text_block"), - // eq(textBlocks.state, STATES.published), - // ), - // ) - // .leftJoin( - // files, - // and( - // eq(lessonItems.lessonItemId, files.id), - // eq(lessonItems.lessonItemType, "file"), - // eq(files.state, STATES.published), - // ), - // ) - // .leftJoin( - // studentCompletedLessonItems, - // and( - // eq(studentCompletedLessonItems.lessonItemId, lessonItems.id), - // eq(studentCompletedLessonItems.lessonId, lessonId), - // eq(studentCompletedLessonItems.courseId, courseId), - // ), - // ) - // .where(and(eq(lessonItems.lessonId, lessonId))) - // .orderBy(lessonItems.displayOrder); - // } - - // async getQuestionAnswers( - // questionId: UUIDType, - // userId: UUIDType, - // courseId: UUIDType, - // lessonId: UUIDType, - // lessonType: string, - // lessonRated: boolean, - // trx?: PostgresJsDatabase, - // ) { - // const dbInstance = trx ?? this.db; - - // return await dbInstance - // .select({ - // id: questionAnswerOptions.id, - // optionText: questionAnswerOptions.optionText, - // position: questionAnswerOptions.position, - // isStudentAnswer: sql` - // CASE - // WHEN ${studentQuestionAnswers.id} IS NULL THEN NULL - // WHEN ${studentQuestionAnswers.answer}->>CAST(${questionAnswerOptions.position} AS text) = ${questionAnswerOptions.optionText} AND - // ${questions.questionType} IN (${QUESTION_TYPE.fill_in_the_blanks_dnd.key}, ${QUESTION_TYPE.fill_in_the_blanks_text.key}) - // THEN TRUE - // WHEN EXISTS ( - // SELECT 1 - // FROM jsonb_object_keys(${studentQuestionAnswers.answer}) AS key - // WHERE ${studentQuestionAnswers.answer}->key = to_jsonb(${questionAnswerOptions.optionText}) - // ) AND ${questions.questionType} NOT IN (${QUESTION_TYPE.fill_in_the_blanks_dnd.key}, ${QUESTION_TYPE.fill_in_the_blanks_text.key}) - // THEN TRUE - // ELSE FALSE - // END - // `, - // isCorrect: sql` - // CASE - // WHEN ${lessonType} = 'quiz' AND ${lessonRated} THEN - // ${questionAnswerOptions.isCorrect} - // ELSE NULL - // END - // `, - // }) - // .from(questionAnswerOptions) - // .leftJoin(questions, eq(questionAnswerOptions.questionId, questions.id)) - // .leftJoin( - // studentQuestionAnswers, - // and( - // eq(studentQuestionAnswers.questionId, questionAnswerOptions.questionId), - // eq(studentQuestionAnswers.lessonId, lessonId), - // eq(studentQuestionAnswers.courseId, courseId), - // eq(studentQuestionAnswers.studentId, userId), - // ), - // ) - // .where(eq(questionAnswerOptions.questionId, questionId)) - // .groupBy( - // questionAnswerOptions.id, - // questionAnswerOptions.optionText, - // questionAnswerOptions.position, - // studentQuestionAnswers.id, - // studentQuestionAnswers.answer, - // questions.questionType, - // ); - // } - - // async checkLessonAssignment(courseId: UUIDType, lessonId: UUIDType, userId: UUIDType) { - // return this.db - // .select({ - // id: lessons.id, - // isFree: sql`COALESCE(${courseLessons.isFree}, FALSE)`, - // isAssigned: sql`CASE WHEN ${studentCourses.id} IS NOT NULL THEN true ELSE false END`, - // }) - // .from(lessons) - // .leftJoin( - // courseLessons, - // and(eq(courseLessons.lessonId, lessons.id), eq(courseLessons.courseId, courseId)), - // ) - // .leftJoin( - // studentCourses, - // and(eq(studentCourses.courseId, courseId), eq(studentCourses.studentId, userId)), - // ) - // .where( - // and( - // eq(lessons.archived, false), - // eq(lessons.id, lessonId), - // eq(lessons.state, STATES.published), - // ), - // ); - // } - - // async completedLessonItem(courseId: UUIDType, lessonId: UUIDType) { - // return await this.db - // .selectDistinct({ - // lessonItemId: studentCompletedLessonItems.lessonItemId, - // }) - // .from(studentCompletedLessonItems) - // .where( - // and( - // eq(studentCompletedLessonItems.lessonId, lessonId), - // eq(studentCompletedLessonItems.courseId, courseId), - // ), - // ); - // } - - // async lessonProgress(courseId: UUIDType, lessonId: UUIDType, userId: UUIDType, isQuiz = false) { - // const conditions = [ - // eq(studentLessonsProgress.studentId, userId), - // eq(studentLessonsProgress.lessonId, lessonId), - // eq(studentLessonsProgress.courseId, courseId), - // ]; - - // if (isQuiz) { - // conditions.push(eq(lessons.type, LESSON_TYPE.quiz.key)); - // } - - // const [lessonProgress] = await this.db - // .select({ - // quizCompleted: sql` - // CASE - // WHEN ${studentLessonsProgress.quizCompleted} THEN - // ${studentLessonsProgress.quizCompleted} - // ELSE false - // END`, - // lessonItemCount: lessons.itemsCount, - // completedLessonItemCount: studentLessonsProgress.completedLessonItemCount, - // quizScore: sql`${studentLessonsProgress.quizScore}`, - // }) - // .from(studentLessonsProgress) - // .leftJoin(lessons, eq(studentLessonsProgress.lessonId, lessons.id)) - // .where(and(...conditions)); - - // return lessonProgress; - // } - - // async getLessonItemCount(lessonId: UUIDType) { - // const [lessonItemCount] = await this.db - // .select({ count: count(lessonItems.id) }) - // .from(lessonItems) - // .where(eq(lessonItems.lessonId, lessonId)); - - // return lessonItemCount; - // } - - // async completedLessonItemsCount(courseId: UUIDType, lessonId: UUIDType) { - // const [completedLessonItemsCount] = await this.db - // .selectDistinct({ - // count: count(studentCompletedLessonItems.id), - // }) - // .from(studentCompletedLessonItems) - // .where( - // and( - // eq(studentCompletedLessonItems.lessonId, lessonId), - // eq(studentCompletedLessonItems.courseId, courseId), - // ), - // ); - - // return completedLessonItemsCount; - // } - - // async getQuizScore(courseId: UUIDType, lessonId: UUIDType, userId: UUIDType) { - // const questions = await this.db - // .select({ - // questionId: lessonItems.lessonItemId, - // }) - // .from(lessonItems) - // .where(eq(lessonItems.lessonId, lessonId)); - - // const questionIds = questions.map((question) => question.questionId); - - // const [quizScore] = await this.db - // .select({ - // quizScore: sql`sum(case when ${studentQuestionAnswers.isCorrect} then 1 else 0 end)`, - // }) - // .from(studentQuestionAnswers) - // .where( - // and( - // eq(studentQuestionAnswers.studentId, userId), - // inArray(studentQuestionAnswers.questionId, questionIds), - // ), - // ) - // .groupBy(studentQuestionAnswers.studentId); - - // return quizScore.quizScore; - // } - - // async getLessonsDetails(userId: UUIDType, courseId: UUIDType, lessonId?: UUIDType) { - // const conditions = [ - // eq(courseLessons.courseId, courseId), - // eq(lessons.archived, false), - // eq(lessons.state, STATES.published), - // isNotNull(lessons.id), - // isNotNull(lessons.title), - // isNotNull(lessons.description), - // isNotNull(lessons.imageUrl), - // ]; - // if (lessonId) conditions.push(eq(lessons.id, lessonId)); - - // return await this.db - // .select({ - // id: lessons.id, - // title: lessons.title, - // type: lessons.type, - // isSubmitted: sql` - // EXISTS ( - // SELECT 1 - // FROM ${studentLessonsProgress} - // WHERE ${studentLessonsProgress.lessonId} = ${lessons.id} - // AND ${studentLessonsProgress.courseId} = ${courseId} - // AND ${studentLessonsProgress.studentId} = ${userId} - // AND ${studentLessonsProgress.quizCompleted} - // )::BOOLEAN`, - // description: sql`${lessons.description}`, - // imageUrl: sql`${lessons.imageUrl}`, - // itemsCount: sql` - // (SELECT COUNT(*) - // FROM ${lessonItems} - // WHERE ${lessonItems.lessonId} = ${lessons.id} - // AND ${lessonItems.lessonItemType} != ${LESSON_ITEM_TYPE.text_block.key})`, - // itemsCompletedCount: sql` - // (SELECT COUNT(*) - // FROM ${studentCompletedLessonItems} - // WHERE ${studentCompletedLessonItems.lessonId} = ${lessons.id} - // AND ${studentCompletedLessonItems.courseId} = ${courseId} - // AND ${studentCompletedLessonItems.studentId} = ${userId})`, - // lessonProgress: sql` - // (CASE - // WHEN ( - // SELECT COUNT(*) - // FROM ${lessonItems} - // WHERE ${lessonItems.lessonId} = ${lessons.id} - // AND ${lessonItems.lessonItemType} != ${LESSON_ITEM_TYPE.text_block.key} - // ) = ( - // SELECT COUNT(*) - // FROM ${studentCompletedLessonItems} - // WHERE ${studentCompletedLessonItems.lessonId} = ${lessons.id} - // AND ${studentCompletedLessonItems.courseId} = ${courseId} - // AND ${studentCompletedLessonItems.studentId} = ${userId} - // ) - // THEN ${LessonProgress.completed} - // WHEN ( - // SELECT COUNT(*) - // FROM ${studentCompletedLessonItems} - // WHERE ${studentCompletedLessonItems.lessonId} = ${lessons.id} - // AND ${studentCompletedLessonItems.courseId} = ${courseId} - // AND ${studentCompletedLessonItems.studentId} = ${userId} - // ) > 0 - // THEN ${LessonProgress.inProgress} - // ELSE ${LessonProgress.notStarted} - // END) - // `, - // isFree: courseLessons.isFree, - // }) - // .from(courseLessons) - // .innerJoin(lessons, eq(courseLessons.lessonId, lessons.id)) - // .where(and(...conditions)); - // } - - // async completeQuiz( - // courseId: UUIDType, - // lessonId: UUIDType, - // userId: UUIDType, - // completedLessonItemCount: number, - // quizScore: number, - // ) { - // return await this.db - // .insert(studentLessonsProgress) - // .values({ - // studentId: userId, - // lessonId: lessonId, - // courseId: courseId, - // quizCompleted: true, - // completedLessonItemCount, - // quizScore, - // }) - // .onConflictDoUpdate({ - // target: [ - // studentLessonsProgress.studentId, - // studentLessonsProgress.lessonId, - // studentLessonsProgress.courseId, - // ], - // set: { - // quizCompleted: true, - // completedLessonItemCount, - // quizScore, - // }, - // }) - // .returning(); - // } - - // async getQuizProgress(courseId: UUIDType, lessonId: UUIDType, userId: UUIDType) { - // const [quizProgress] = await this.db - // .select({ - // quizCompleted: sql` - // CASE - // WHEN ${studentLessonsProgress.quizCompleted} THEN - // ${studentLessonsProgress.quizCompleted} - // ELSE false - // END`, - // completedAt: sql`${studentLessonsProgress.completedAt}`, - // }) - // .from(studentLessonsProgress) - // .where( - // and( - // eq(studentLessonsProgress.studentId, userId), - // eq(studentLessonsProgress.lessonId, lessonId), - // eq(studentLessonsProgress.courseId, courseId), - // ), - // ); - - // return quizProgress; - // } - - // async getQuestionsIdsByLessonId(lessonId: UUIDType, trx?: PostgresJsDatabase) { - // const dbInstance = trx ?? this.db; - - // const questionIds = await dbInstance - // .select({ - // questionId: studentQuestionAnswers.questionId, - // }) - // .from(studentQuestionAnswers) - // .leftJoin(lessonItems, eq(studentQuestionAnswers.questionId, lessonItems.lessonItemId)) - // .where(eq(lessonItems.lessonId, lessonId)); - - // return questionIds; - // } - - // async getOpenQuestionStudentAnswer( - // lessonId: UUIDType, - // questionId: UUIDType, - // userId: UUIDType, - // lessonType: string, - // lessonRated: boolean, - // ) { - // return await this.db - // .select({ - // id: studentQuestionAnswers.id, - // optionText: sql`${studentQuestionAnswers.answer}->'1'`, - // isStudentAnswer: sql`true`, - // position: sql`1`, - // isCorrect: sql` - // CASE - // WHEN ${lessonType} = 'quiz' AND ${lessonRated} THEN - // ${studentQuestionAnswers.isCorrect} - // ELSE null - // END - // `, - // }) - // .from(studentQuestionAnswers) - // .where( - // and( - // eq(studentQuestionAnswers.lessonId, lessonId), - // eq(studentQuestionAnswers.questionId, questionId), - // eq(studentQuestionAnswers.studentId, userId), - // ), - // ) - - // .limit(1); - // } - - // async getFillInTheBlanksStudentAnswers( - // userId: UUIDType, - // questionId: UUIDType, - // lessonId: UUIDType, - // ) { - // return await this.db - // .select({ - // id: studentQuestionAnswers.id, - // answer: sql>`${studentQuestionAnswers.answer}`, - // isCorrect: studentQuestionAnswers.isCorrect, - // }) - // .from(studentQuestionAnswers) - // .where( - // and( - // eq(studentQuestionAnswers.lessonId, lessonId), - // eq(studentQuestionAnswers.questionId, questionId), - // eq(studentQuestionAnswers.studentId, userId), - // ), - // ) - // .limit(1); - // } - - // async getLastInteractedOrNextLessonItemForUser(userId: UUIDType) { - // const [lastLessonItem] = await this.db - // .select({ - // id: sql`${studentCompletedLessonItems.lessonItemId}`, - // lessonId: sql`${studentCompletedLessonItems.lessonId}`, - // courseId: sql`${studentCompletedLessonItems.courseId}`, - // courseTitle: sql`${courses.title}`, - // courseDescription: sql`${courses.description}`, - // }) - // .from(studentLessonsProgress) - // .leftJoin(studentCompletedLessonItems, and(eq(studentCompletedLessonItems.studentId, userId))) - // .where( - // and( - // eq(studentCompletedLessonItems.studentId, userId), - // eq(studentLessonsProgress.lessonId, studentCompletedLessonItems.lessonId), - // eq(studentLessonsProgress.courseId, studentCompletedLessonItems.courseId), - // isNull(studentLessonsProgress.completedAt), - // ), - // ) - // .leftJoin(courses, eq(studentCompletedLessonItems.courseId, courses.id)) - // .orderBy(desc(studentCompletedLessonItems.updatedAt)) - // .limit(1); - - // return lastLessonItem; - // } - - // async getQuizQuestionsAnswers( - // courseId: UUIDType, - // lessonId: UUIDType, - // userId: UUIDType, - // onlyCorrect = false, - // ) { - // const conditions = [ - // eq(studentQuestionAnswers.courseId, courseId), - // eq(studentQuestionAnswers.lessonId, lessonId), - // eq(studentQuestionAnswers.studentId, userId), - // ]; - - // if (onlyCorrect) conditions.push(eq(studentQuestionAnswers.isCorrect, true)); - - // return this.db - // .select({ - // questionId: studentQuestionAnswers.questionId, - // isCorrect: studentQuestionAnswers.isCorrect, - // }) - // .from(studentQuestionAnswers) - // .where(and(...conditions)) - // .orderBy(studentQuestionAnswers.questionId); - // } - - // async removeQuestionsAnswer( - // courseId: UUIDType, - // lessonId: UUIDType, - // questionIds: { questionId: string }[], - // userId: UUIDType, - // trx?: PostgresJsDatabase, - // ) { - // const dbInstance = trx ?? this.db; - - // return await dbInstance.delete(studentQuestionAnswers).where( - // and( - // eq(studentQuestionAnswers.courseId, courseId), - // eq(studentQuestionAnswers.lessonId, lessonId), - // eq(studentQuestionAnswers.studentId, userId), - // inArray( - // studentQuestionAnswers.questionId, - // questionIds.map((q) => q.questionId), - // ), - // ), - // ); - // } - - async getLessonsProgressByCourseId( - courseId: UUIDType, - userId: UUIDType, - trx?: PostgresJsDatabase, - ) { - const dbInstance = trx ?? this.db; - - return await dbInstance - .select({ - lessonId: studentLessonProgress.lessonId, - completedLessonCount: studentLessonProgress.completedQuestionCount, - quizCompleted: studentLessonProgress.completedAt, - quizScore: studentLessonProgress.quizScore, - }) - .from(studentLessonProgress) - .leftJoin(lessons, eq(studentLessonProgress.lessonId, lessons.id)) - .leftJoin(chapters, eq(lessons.chapterId, chapters.id)) - .where(and(eq(chapters.courseId, courseId), eq(studentLessonProgress.studentId, userId))); - } - - // async setCorrectAnswerForStudentAnswer( - // courseId: UUIDType, - // lessonId: UUIDType, - // questionId: UUIDType, - // userId: UUIDType, - // isCorrect: boolean, - // trx?: PostgresJsDatabase, - // ) { - // const dbInstance = trx ?? this.db; - - // return await dbInstance - // .update(studentQuestionAnswers) - // .set({ - // isCorrect, - // }) - // .where( - // and( - // eq(studentQuestionAnswers.studentId, userId), - // eq(studentQuestionAnswers.questionId, questionId), - // eq(studentQuestionAnswers.lessonId, lessonId), - // eq(studentQuestionAnswers.courseId, courseId), - // ), - // ); - // } - - // async retireQuizProgress( - // courseId: UUIDType, - // lessonId: UUIDType, - // userId: UUIDType, - // trx?: PostgresJsDatabase, - // ) { - // const dbInstance = trx ?? this.db; - - // return await dbInstance - // .update(studentLessonsProgress) - // .set({ quizCompleted: false }) - // .where( - // and( - // eq(studentLessonsProgress.studentId, userId), - // eq(studentLessonsProgress.lessonId, lessonId), - // eq(studentLessonsProgress.courseId, courseId), - // ), - // ); - // } - - // async removeStudentCompletedLessonItems( - // courseId: UUIDType, - // lessonId: UUIDType, - // userId: UUIDType, - // trx?: PostgresJsDatabase, - // ) { - // // TODO: remove this function, not deleting from the database, only clearing variables - // const dbInstance = trx ?? this.db; - - // return await dbInstance - // .delete(studentCompletedLessonItems) - // .where( - // and( - // eq(studentCompletedLessonItems.studentId, userId), - // eq(studentCompletedLessonItems.lessonId, lessonId), - // eq(studentCompletedLessonItems.courseId, courseId), - // ), - // ); - // } - - // async updateStudentLessonProgress(userId: UUIDType, lessonId: UUIDType, courseId: UUIDType) { - // return await this.db - // .update(studentLessonsProgress) - // .set({ - // completedLessonItemCount: sql` - // (SELECT COUNT(*) - // FROM ${studentCompletedLessonItems} - // WHERE ${studentCompletedLessonItems.lessonId} = ${lessonId} - // AND ${studentCompletedLessonItems.courseId} = ${courseId} - // AND ${studentCompletedLessonItems.studentId} = ${userId})`, - // }) - // .where( - // and( - // eq(studentLessonsProgress.courseId, courseId), - // eq(studentLessonsProgress.lessonId, lessonId), - // eq(studentLessonsProgress.studentId, userId), - // ), - // ) - // .returning(); - // } - - // async completeLessonProgress( - // courseId: UUIDType, - // lessonId: UUIDType, - // userId: UUIDType, - // completedAsFreemium: boolean, - // trx?: PostgresJsDatabase, - // ) { - // const dbInstance = trx ?? this.db; - - // return await dbInstance - // .update(studentLessonsProgress) - // .set({ - // completedAt: sql`now()`, - // completedAsFreemium, - // }) - // .where( - // and( - // eq(studentLessonsProgress.courseId, courseId), - // eq(studentLessonsProgress.lessonId, lessonId), - // eq(studentLessonsProgress.studentId, userId), - // ), - // ); - // } -} diff --git a/apps/api/src/lesson/lesson.schema.ts b/apps/api/src/lesson/lesson.schema.ts index d6bbdc6e..e4c9c7ab 100644 --- a/apps/api/src/lesson/lesson.schema.ts +++ b/apps/api/src/lesson/lesson.schema.ts @@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox"; import { UUIDSchema } from "src/common"; -import { PhotoQuestionType, QuestionType } from "./lesson.type"; +import { LESSON_TYPES, PhotoQuestionType, QuestionType } from "./lesson.type"; import type { Static } from "@sinclair/typebox"; @@ -24,7 +24,6 @@ export const questionSchema = Type.Object({ }); export const lessonSchema = Type.Object({ - updatedAt: Type.Optional(Type.String()), id: UUIDSchema, title: Type.String(), type: Type.String(), @@ -33,6 +32,7 @@ export const lessonSchema = Type.Object({ fileS3Key: Type.Optional(Type.String()), fileType: Type.Optional(Type.String()), questions: Type.Optional(Type.Array(questionSchema)), + updatedAt: Type.Optional(Type.String()), }); const lessonQuizSchema = Type.Object({ @@ -46,7 +46,7 @@ const lessonQuizSchema = Type.Object({ questions: Type.Optional(Type.Array(questionSchema)), }); -export const lessonItemSchema = Type.Object({ +export const adminLessonSchema = Type.Object({ id: UUIDSchema, type: Type.String(), displayOrder: Type.Number(), @@ -73,13 +73,35 @@ export const createQuizLessonSchema = Type.Intersect([ }), ]); +export const questionDetails = Type.Object({ + questions: Type.Array(Type.Any()), + questionCount: Type.Number(), + correctAnswerCount: Type.Union([Type.Number(), Type.Null()]), + wrongAnswerCount: Type.Union([Type.Number(), Type.Null()]), + score: Type.Union([Type.Number(), Type.Null()]), +}); + +export const lessonShowSchema = Type.Object({ + id: UUIDSchema, + title: Type.String(), + type: Type.Enum(LESSON_TYPES), + description: Type.String(), + fileType: Type.Union([Type.String(), Type.Null()]), + fileUrl: Type.Union([Type.String(), Type.Null()]), + quizDetails: Type.Optional(questionDetails), + displayOrder: Type.Number(), +}); + export const updateLessonSchema = Type.Partial(createLessonSchema); export const updateQuizLessonSchema = Type.Partial(createQuizLessonSchema); -export type LessonItemWithContentSchema = Static; +export type AdminLessonWithContentSchema = Static; export type CreateLessonBody = Static; export type UpdateLessonBody = Static; export type UpdateQuizLessonBody = Static; export type CreateQuizLessonBody = Static; +// TODO: duplicate +export type OptionBody = Static; export type QuestionBody = Static; export type QuestionSchema = Static; +export type LessonShow = Static; diff --git a/apps/api/src/lesson/lesson.type.ts b/apps/api/src/lesson/lesson.type.ts index 45b18d34..3725b221 100644 --- a/apps/api/src/lesson/lesson.type.ts +++ b/apps/api/src/lesson/lesson.type.ts @@ -1,9 +1,9 @@ export const LESSON_TYPES = { - textBlock: "text_block", - file: "file", - presentation: "presentation", - video: "video", - quiz: "quiz", + TEXT: "text", + FILE: "file", + PRESENTATION: "presentation", + VIDEO: "video", + QUIZ: "quiz", } as const; export enum QuestionType { diff --git a/apps/api/src/lesson/old.service.ts b/apps/api/src/lesson/old.service.ts deleted file mode 100644 index e080babe..00000000 --- a/apps/api/src/lesson/old.service.ts +++ /dev/null @@ -1,612 +0,0 @@ -// import { -// ConflictException, -// Inject, -// Injectable, -// NotFoundException, -// UnauthorizedException, -// } from "@nestjs/common"; -// import { EventBus } from "@nestjs/cqrs"; -// import { isNull } from "lodash"; -// import { match, P } from "ts-pattern"; - -// import { DatabasePg } from "src/common"; -// import { QuizCompletedEvent } from "src/events"; -// import { QUESTION_TYPE } from "src/questions/schema/questions.types"; - -// import { LESSON_ITEM_TYPE, LESSON_TYPE } from "./chapter.type"; - -// import type { -// LessonItemResponse, -// LessonItemWithContentSchema, -// QuestionAnswer, -// QuestionResponse, -// QuestionWithContent, -// } from "./schemas/lessonItem.schema"; -// import type { UUIDType } from "src/common"; -// import { FileService } from "src/file/file.service"; -// import { ChapterRepository } from "./repositories/chapter.repository"; -// import { ShowChapterResponse } from "./schemas/lesson.schema"; -// import { ChapterProgress } from "./schemas/lesson.types"; - -// @Injectable() -// export class ChapterService { -// constructor( -// @Inject("DB") private readonly db: DatabasePg, -// private readonly filesService: FileService, -// private readonly chapterRepository: ChapterRepository, -// private readonly eventBus: EventBus, -// ) {} - -// async getChapter( -// id: UUIDType, -// courseId: UUIDType, -// userId: UUIDType, -// isAdmin?: boolean, -// ): Promise { -// const [courseAccess] = await this.chapterRepository.checkLessonAssignment(courseId, id, userId); -// const chapter = await this.chapterRepository.getLessonForUser(courseId, id, userId); - -// if (!isAdmin && !courseAccess && !lesson.isFree) -// throw new UnauthorizedException("You don't have access to this lesson"); - -// if (!lesson) throw new NotFoundException("Lesson not found"); - -// const getImageUrl = async (url: string) => { -// if (!url || url.startsWith("https://")) return url; -// return await this.filesService.getFileUrl(url); -// }; - -// const imageUrl = await getImageUrl(lesson.imageUrl); - -// const completedLessonItems = await this.chapterRepository.completedLessonItem( -// courseId, -// lesson.id, -// ); - -// if (lesson.type !== LESSON_TYPE.quiz.key) { -// const lessonItems = await this.getLessonItems(lesson, courseId, userId); - -// const completableLessonItems = lessonItems.filter( -// (item) => item.lessonItemType !== LESSON_ITEM_TYPE.text_block.key, -// ); - -// return { -// ...lesson, -// imageUrl, -// lessonItems: lessonItems, -// lessonProgress: -// completableLessonItems.length === 0 -// ? ChapterProgress.notStarted -// : completableLessonItems.length > 0 -// ? ChapterProgress.inProgress -// : ChapterProgress.completed, -// itemsCompletedCount: completedLessonItems.length, -// }; -// } - -// const lessonProgress = await this.chapterRepository.lessonProgress( -// courseId, -// lesson.id, -// userId, -// true, -// ); - -// if (!lessonProgress && !isAdmin && !lesson.isFree) -// throw new NotFoundException("Lesson progress not found"); - -// const isAdminOrFreeLessonWithoutLessonProgress = (isAdmin || lesson.isFree) && !lessonProgress; - -// const questionLessonItems = await this.getLessonQuestions( -// lesson, -// courseId, -// userId, -// isAdminOrFreeLessonWithoutLessonProgress ? false : lessonProgress.quizCompleted, -// ); - -// return { -// ...lesson, -// imageUrl, -// lessonItems: questionLessonItems, -// itemsCompletedCount: isAdminOrFreeLessonWithoutLessonProgress -// ? 0 -// : lessonProgress.completedLessonItemCount, -// quizScore: isAdminOrFreeLessonWithoutLessonProgress ? 0 : lessonProgress.quizScore, -// lessonProgress: isAdminOrFreeLessonWithoutLessonProgress -// ? LessonProgress.notStarted -// : lessonProgress.completedLessonItemCount === 0 -// ? LessonProgress.notStarted -// : lessonProgress.completedLessonItemCount > 0 -// ? LessonProgress.inProgress -// : LessonProgress.completed, -// }; -// } - -// async evaluationQuiz(courseId: UUIDType, lessonId: UUIDType, userId: UUIDType) { -// const [accessCourseLessons] = await this.chapterRepository.checkLessonAssignment( -// courseId, -// lessonId, -// userId, -// ); - -// if (!accessCourseLessons.isAssigned && !accessCourseLessons.isFree) -// throw new UnauthorizedException("You don't have assignment to this lesson"); - -// const quizProgress = await this.chapterRepository.getQuizProgress(courseId, lessonId, userId); - -// if (quizProgress?.quizCompleted) throw new ConflictException("Quiz already completed"); - -// const lessonItemsCount = await this.chapterRepository.getLessonItemCount(lessonId); - -// const completedLessonItemsCount = await this.chapterRepository.completedLessonItemsCount( -// courseId, -// lessonId, -// ); - -// if (lessonItemsCount.count !== completedLessonItemsCount.count) -// throw new ConflictException("Lesson is not completed"); - -// const evaluationResult = await this.evaluationsQuestions(courseId, lessonId, userId); - -// if (!evaluationResult) return false; - -// const quizScore = await this.chapterRepository.getQuizScore(courseId, lessonId, userId); - -// const updateQuizResult = await this.chapterRepository.completeQuiz( -// courseId, -// lessonId, -// userId, -// completedLessonItemsCount.count, -// quizScore, -// ); - -// if (!updateQuizResult) return false; - -// return true; -// } - -// private async evaluationsQuestions(courseId: UUIDType, lessonId: UUIDType, userId: UUIDType) { -// const lesson = await this.chapterRepository.getLessonForUser(courseId, lessonId, userId); -// const questionLessonItems = await this.getLessonQuestionsToEvaluation( -// lesson, -// courseId, -// userId, -// true, -// ); - -// try { -// await this.db.transaction(async (trx) => { -// await Promise.all( -// questionLessonItems.map(async (questionLessonItem) => { -// const answers = await this.chapterRepository.getQuestionAnswers( -// questionLessonItem.content.id, -// userId, -// courseId, -// lessonId, -// lesson.type, -// true, -// trx, -// ); - -// const passQuestion = await match(questionLessonItem.content.questionType) -// .returnType>() -// .with(QUESTION_TYPE.fill_in_the_blanks_text.key, async () => { -// const question = questionLessonItem.content; -// let passQuestion = true; - -// for (const answer of question.questionAnswers) { -// if (answer.optionText != answer.studentAnswerText) { -// passQuestion = false; -// break; -// } -// } - -// return passQuestion; -// }) -// .with(QUESTION_TYPE.fill_in_the_blanks_dnd.key, async () => { -// const question = questionLessonItem.content; -// let passQuestion = true; - -// for (const answer of question.questionAnswers) { -// if (answer.isStudentAnswer != answer.isCorrect) { -// passQuestion = false; -// break; -// } -// } - -// return passQuestion; -// }) -// .otherwise(async () => { -// let passQuestion = true; -// for (const answer of answers) { -// if ( -// answer.isStudentAnswer !== answer.isCorrect || -// isNull(answer.isStudentAnswer) -// ) { -// passQuestion = false; -// break; -// } -// } - -// return passQuestion; -// }); - -// await this.chapterRepository.setCorrectAnswerForStudentAnswer( -// courseId, -// lessonId, -// questionLessonItem.content.id, -// userId, -// passQuestion, -// trx, -// ); -// }), -// ); -// }); - -// const correctAnswers = await this.chapterRepository.getQuizQuestionsAnswers( -// courseId, -// lessonId, -// userId, -// true, -// ); -// const correctAnswerCount = correctAnswers.length; -// const totalQuestions = questionLessonItems.length; -// const wrongAnswerCount = totalQuestions - correctAnswerCount; -// const score = Math.round((correctAnswerCount / totalQuestions) * 100); - -// this.eventBus.publish( -// new QuizCompletedEvent( -// userId, -// courseId, -// lessonId, -// correctAnswerCount, -// wrongAnswerCount, -// score, -// ), -// ); - -// return true; -// } catch (error) { -// console.log("error", error); -// return false; -// } -// } - -// async clearQuizProgress(courseId: UUIDType, lessonId: UUIDType, userId: UUIDType) { -// const [accessCourseLessons] = await this.chapterRepository.checkLessonAssignment( -// courseId, -// lessonId, -// userId, -// ); - -// if (!accessCourseLessons) -// throw new UnauthorizedException("You don't have assignment to this lesson"); - -// const quizProgress = await this.chapterRepository.lessonProgress( -// courseId, -// lessonId, -// userId, -// true, -// ); - -// if (!quizProgress) throw new NotFoundException("Lesson progress not found"); - -// try { -// return await this.db.transaction(async (trx) => { -// const questionIds = await this.chapterRepository.getQuestionsIdsByLessonId(lessonId); - -// await this.chapterRepository.retireQuizProgress(courseId, lessonId, userId, trx); - -// await this.chapterRepository.removeQuestionsAnswer( -// courseId, -// lessonId, -// questionIds, -// userId, -// trx, -// ); - -// await this.chapterRepository.removeStudentCompletedLessonItems( -// courseId, -// lessonId, -// userId, -// trx, -// ); - -// return true; -// }); -// } catch (error) { -// return false; -// } -// } - -// private async getLessonItems(lesson: Lesson, courseId: UUIDType, userId: UUIDType) { -// const lessonItemsList = await this.chapterRepository.getLessonItems(lesson.id, courseId); -// const validLessonItemsList = lessonItemsList.filter(this.isValidItem); - -// return await Promise.all( -// validLessonItemsList.map( -// async (item) => -// await this.processLessonItem(item, userId, courseId, lesson.id, lesson.type), -// ), -// ); -// } - -// private async getLessonQuestions( -// lesson: Lesson, -// courseId: UUIDType, -// userId: UUIDType, -// quizCompleted: boolean, -// ) { -// const questionItemsForLesson = await this.chapterRepository.getQuestionItems( -// lesson.id, -// userId, -// lesson.type, -// quizCompleted, -// ); - -// return await Promise.all( -// questionItemsForLesson.map(async (item) => { -// const { lessonItemId, questionData, lessonItemType, displayOrder, passQuestion } = item; - -// if (isNull(questionData)) throw new Error("Question not found"); - -// const content = await this.processQuestionItem( -// { lessonItemId, displayOrder, lessonItemType, questionData }, -// userId, -// courseId, -// lesson.id, -// lesson.type, -// quizCompleted, -// passQuestion, -// ); - -// return { -// lessonItemId: item.lessonItemId, -// lessonItemType: item.lessonItemType, -// displayOrder: item.displayOrder, -// content, -// }; -// }), -// ); -// } - -// private async getLessonQuestionsToEvaluation( -// lesson: Lesson, -// courseId: UUIDType, -// userId: UUIDType, -// quizCompleted: boolean, -// ) { -// const lessonItemsList = await this.chapterRepository.getLessonItems(lesson.id, courseId); -// const validLessonItemsList = lessonItemsList.filter(this.isValidItem); - -// return await Promise.all( -// validLessonItemsList.map(async (item) => { -// const { lessonItemId, questionData, lessonItemType, displayOrder } = item; - -// if (isNull(questionData)) throw new Error("Question not found"); - -// const content = await this.processQuestionItem( -// { lessonItemId, displayOrder, lessonItemType, questionData }, -// userId, -// courseId, -// lesson.id, -// lesson.type, -// quizCompleted, -// null, -// ); - -// return { -// lessonItemId: item.lessonItemId, -// lessonItemType: item.lessonItemType, -// displayOrder: item.displayOrder, -// content, -// }; -// }), -// ); -// } - -// private async processLessonItem( -// item: LessonItemWithContentSchema, -// userId: UUIDType, -// courseId: UUIDType, -// lessonId: UUIDType, -// lessonType: string, -// ): Promise { -// const content = await match(item) -// .returnType>() -// .with( -// { lessonItemType: LESSON_ITEM_TYPE.question.key, questionData: P.not(P.nullish) }, -// async (item) => { -// const { lessonItemId, questionData, lessonItemType, displayOrder } = item; -// return this.processQuestionItem( -// { lessonItemId, displayOrder, lessonItemType, questionData }, -// userId, -// courseId, -// lessonId, -// lessonType, -// false, -// null, -// ); -// }, -// ) -// .with( -// { lessonItemType: LESSON_ITEM_TYPE.text_block.key, textBlockData: P.not(P.nullish) }, -// async (item) => ({ -// id: item.textBlockData.id, -// body: item.textBlockData.body ?? "", -// state: item.textBlockData.state ?? "", -// title: item.textBlockData.title, -// }), -// ) -// .with( -// { lessonItemType: LESSON_ITEM_TYPE.file.key, fileData: P.not(P.nullish) }, -// async (item) => ({ -// id: item.fileData.id, -// title: item.fileData.title, -// type: item.fileData.type, -// body: item.fileData.body, -// url: (item.fileData.url as string).startsWith("https://") -// ? item.fileData.url -// : await this.filesService.getFileUrl(item.fileData.url), -// }), -// ) -// .otherwise(() => { -// throw new Error(`Unknown item type: ${item.lessonItemType}`); -// }); - -// return { -// lessonItemId: item.lessonItemId, -// lessonItemType: item.lessonItemType, -// displayOrder: item.displayOrder, -// isCompleted: item.isCompleted, -// content, -// }; -// } - -// private async processQuestionItem( -// item: QuestionWithContent, -// userId: UUIDType, -// courseId: UUIDType, -// lessonId: UUIDType, -// lessonType: string, -// lessonRated: boolean, -// passQuestion: boolean | null, -// ): Promise { -// const questionAnswers: QuestionAnswer[] = await this.chapterRepository.getQuestionAnswers( -// item.questionData.id, -// userId, -// courseId, -// lessonId, -// lessonType, -// lessonRated, -// ); - -// if ( -// item.questionData.questionType !== QUESTION_TYPE.open_answer.key && -// item.questionData.questionType !== QUESTION_TYPE.fill_in_the_blanks_text.key && -// item.questionData.questionType !== QUESTION_TYPE.fill_in_the_blanks_dnd.key -// ) { -// return { -// id: item.questionData.id, -// questionType: item.questionData.questionType, -// questionBody: item.questionData.questionBody, -// questionAnswers, -// passQuestion, -// }; -// } - -// if (item.questionData.questionType === QUESTION_TYPE.open_answer.key) { -// const studentAnswer = await this.chapterRepository.getOpenQuestionStudentAnswer( -// lessonId, -// item.questionData.id, -// userId, -// lessonType, -// lessonRated, -// ); - -// return { -// id: item.questionData.id, -// questionType: item.questionData.questionType, -// questionBody: item.questionData.questionBody, -// questionAnswers: studentAnswer, -// passQuestion, -// }; -// } - -// const [studentAnswers] = await this.chapterRepository.getFillInTheBlanksStudentAnswers( -// userId, -// item.questionData.id, -// lessonId, -// ); -// // TODO: refactor DB query -// if (item.questionData.questionType == QUESTION_TYPE.fill_in_the_blanks_text.key) { -// const result = !!studentAnswers?.answer -// ? Object.keys(studentAnswers.answer).map((key) => { -// const position = parseInt(key); - -// const studentAnswerText = studentAnswers.answer[ -// key as keyof typeof studentAnswers.answer -// ] as string; -// const correctAnswerToStudentAnswer = questionAnswers.find( -// (answer) => answer.position === position, -// ); -// const isCorrect = correctAnswerToStudentAnswer -// ? correctAnswerToStudentAnswer.isCorrect -// : false; -// const isStudentAnswer = correctAnswerToStudentAnswer?.optionText === studentAnswerText; - -// return { -// id: studentAnswers.id, -// optionText: correctAnswerToStudentAnswer?.optionText ?? "", -// position: position, -// isStudentAnswer, -// studentAnswerText: -// (lessonRated && lessonType === LESSON_TYPE.quiz.key) || -// lessonType !== LESSON_TYPE.quiz.key -// ? studentAnswerText -// : null, -// isCorrect, -// }; -// }) -// : []; - -// const canShowSolutionExplanation = -// !!studentAnswers?.answer && lessonRated && lessonType === LESSON_TYPE.quiz.key; - -// return { -// id: item.questionData.id, -// questionType: item.questionData.questionType, -// questionBody: item.questionData.questionBody, -// solutionExplanation: canShowSolutionExplanation -// ? item.questionData.solutionExplanation -// : null, -// questionAnswers: result, -// passQuestion, -// }; -// } - -// const result = questionAnswers.map((answer) => { -// return { -// id: answer.id, -// optionText: answer.optionText, -// position: -// (lessonRated && answer.isCorrect) || -// (lessonType !== LESSON_TYPE.quiz.key && -// typeof answer?.position === "number" && -// studentAnswers?.answer[answer.position]) -// ? answer.position -// : null, -// isStudentAnswer: lessonRated ? answer.isStudentAnswer : null, -// studentAnswerText: -// typeof answer?.position === "number" ? studentAnswers?.answer[answer.position] : null, -// isCorrect: lessonRated ? answer.isCorrect : null, -// }; -// }); - -// const canShowSolutionExplanation = -// !!studentAnswers?.answer && lessonRated && lessonType === LESSON_TYPE.quiz.key; - -// return { -// id: item.questionData.id, -// questionType: item.questionData.questionType, -// questionBody: item.questionData.questionBody, -// questionAnswers: result, -// solutionExplanation: canShowSolutionExplanation -// ? item.questionData.solutionExplanation -// : null, -// passQuestion, -// }; -// } - -// private isValidItem(item: any): boolean { -// switch (item.lessonItemType) { -// case LESSON_ITEM_TYPE.question.key: -// return !!item.questionData; -// case LESSON_ITEM_TYPE.text_block.key: -// return !!item.textBlockData; -// case LESSON_ITEM_TYPE.file.key: -// return !!item.fileData; -// default: -// return false; -// } -// } -// } diff --git a/apps/api/src/lesson/oldadminChapter.repository.ts b/apps/api/src/lesson/oldadminChapter.repository.ts deleted file mode 100644 index 0161fac9..00000000 --- a/apps/api/src/lesson/oldadminChapter.repository.ts +++ /dev/null @@ -1,335 +0,0 @@ -// import { Inject, Injectable } from "@nestjs/common"; -// import { eq, and, sql, isNotNull, count } from "drizzle-orm"; - -// import { DatabasePg, type UUIDType } from "src/common"; -// import { -// lessons, -// courseLessons, -// lessonItems, -// files, -// questions, -// textBlocks, -// questionAnswerOptions, -// } from "src/storage/schema"; - -// import { LESSON_ITEM_TYPE } from "../chapter.type"; - -// import type { CreateLessonBody, UpdateLessonBody } from "../schemas/lesson.schema"; -// import type { LessonItemWithContentSchema } from "../schemas/lessonItem.schema"; -// import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -// import type * as schema from "src/storage/schema"; - -// @Injectable() -// export class AdminChapterRepository { -// constructor(@Inject("DB") private readonly db: DatabasePg) {} - -// async getLessons(conditions: any[], sortOrder: any) { -// return await this.db -// .select({ -// id: lessons.id, -// title: lessons.title, -// description: sql`COALESCE(${lessons.description}, '')`, -// imageUrl: sql`COALESCE(${lessons.imageUrl}, '')`, -// state: lessons.state, -// archived: lessons.archived, -// itemsCount: sql`CAST(COUNT(DISTINCT ${lessonItems.id}) AS INTEGER)`, -// createdAt: lessons.createdAt, -// }) -// .from(lessons) -// .leftJoin(lessonItems, eq(lessonItems.lessonId, lessons.id)) -// .where(and(...conditions)) -// .groupBy(lessons.id) -// .orderBy(sortOrder); -// } - -// async getLessonById(lessonId: UUIDType) { -// const [lesson] = await this.db -// .select({ -// id: lessons.id, -// title: lessons.title, -// description: sql`COALESCE(${lessons.description}, '')`, -// imageUrl: sql`COALESCE(${lessons.imageUrl}, '')`, -// state: lessons.state, -// archived: lessons.archived, -// type: lessons.type, -// }) -// .from(lessons) -// .where(eq(lessons.id, lessonId)); - -// return lesson; -// } - -// async getAvailableLessons(courseId: UUIDType) { -// return await this.db -// .select({ -// id: lessons.id, -// title: lessons.title, -// description: sql`${lessons.description}`, -// imageUrl: sql`${lessons.imageUrl}`, -// itemsCount: sql` -// (SELECT COUNT(*) -// FROM ${lessonItems} -// WHERE ${lessonItems.lessonId} = ${lessons.id} AND ${lessonItems.lessonItemType} != 'text_block')::INTEGER`, -// isFree: sql`COALESCE(${courseLessons.isFree}, false)`, -// }) -// .from(lessons) -// .leftJoin( -// courseLessons, -// and(eq(courseLessons.lessonId, lessons.id), eq(courseLessons.courseId, courseId)), -// ) -// .where( -// and( -// eq(lessons.archived, false), -// eq(lessons.state, "published"), -// isNotNull(lessons.id), -// isNotNull(lessons.title), -// isNotNull(lessons.description), -// isNotNull(lessons.imageUrl), -// ), -// ); -// } - -// async updateDisplayOrderLessonsInCourse(courseId: UUIDType, chapterId: UUIDType) { -// await this.db.execute(sql` -// UPDATE ${courseLessons} -// SET display_order = display_order - 1 -// WHERE course_id = ${courseId} -// AND display_order > ( -// SELECT display_order -// FROM ${courseLessons} -// WHERE course_id = ${courseId} -// AND lesson_id = ${chapterId} -// ) -// `); -// } - -// async updateChapterDisplayOrder(courseId: UUIDType, lessonId: UUIDType) { -// await this.db.transaction(async (trx) => { -// await trx.execute(sql` -// UPDATE ${courseLessons} -// SET display_order = display_order - 1 -// WHERE course_id = ${courseId} -// AND display_order > ( -// SELECT display_order -// FROM ${courseLessons} -// WHERE course_id = ${courseId} -// AND lesson_id = ${lessonId} -// ) -// `); - -// await trx.execute(sql` -// WITH ranked_lessons AS ( -// SELECT lesson_id, row_number() OVER (ORDER BY display_order) AS new_display_order -// FROM ${courseLessons} -// WHERE course_id = ${courseId} -// ) -// UPDATE ${courseLessons} cl -// SET display_order = rl.new_display_order -// FROM ranked_lessons rl -// WHERE cl.lesson_id = rl.lesson_id -// AND cl.course_id = ${courseId} -// `); -// }); -// } - -// async removeCourseLesson(courseId: string, lessonId: string) { -// return await this.db -// .delete(courseLessons) -// .where(and(eq(courseLessons.courseId, courseId), eq(courseLessons.lessonId, lessonId))) -// .returning(); -// } - -// async removeChapterAndReferences( -// chapterId: string, -// lessonItemsList: LessonItemWithContentSchema[], -// ) { -// return await this.db.transaction(async (trx) => { -// for (const lessonItem of lessonItemsList) { -// const { lessonItemType } = lessonItem; -// switch (lessonItemType) { -// case LESSON_ITEM_TYPE.text_block.key: -// if (lessonItem.textBlockData?.id) { -// await trx.delete(textBlocks).where(eq(textBlocks.id, lessonItem.textBlockData.id)); -// } -// break; - -// case LESSON_ITEM_TYPE.question.key: -// if (lessonItem.questionData?.id) { -// await trx.delete(questions).where(eq(questions.id, lessonItem.questionData.id)); -// } -// break; - -// case LESSON_ITEM_TYPE.file.key: -// if (lessonItem.fileData?.id) { -// await trx.delete(files).where(eq(files.id, lessonItem.fileData.id)); -// } -// break; - -// default: -// throw new Error(`Unsupported lesson item type: ${lessonItemType}`); -// } -// } -// await trx.delete(lessonItems).where(eq(lessonItems.lessonId, chapterId)); - -// await trx.delete(courseLessons).where(eq(courseLessons.lessonId, chapterId)); - -// return await trx.delete(lessons).where(eq(lessons.id, chapterId)).returning(); -// }); -// } - -// async getMaxOrderLessonsInCourse(courseId: UUIDType) { -// const [maxOrderResult] = await this.db -// .select({ maxOrder: sql`MAX(${courseLessons.displayOrder})` }) -// .from(courseLessons) -// .where(eq(courseLessons.courseId, courseId)); - -// return maxOrderResult?.maxOrder ?? 0; -// } - -// async getLessonItems(lessonId: UUIDType) { -// return await this.db -// .select({ -// lessonItemType: lessonItems.lessonItemType, -// lessonItemId: lessonItems.id, -// questionData: questions, -// textBlockData: textBlocks, -// fileData: files, -// displayOrder: lessonItems.displayOrder, -// }) -// .from(lessonItems) -// .leftJoin( -// questions, -// and( -// eq(lessonItems.lessonItemId, questions.id), -// eq(lessonItems.lessonItemType, LESSON_ITEM_TYPE.question.key), -// eq(questions.state, "published"), -// ), -// ) -// .leftJoin( -// textBlocks, -// and( -// eq(lessonItems.lessonItemId, textBlocks.id), -// eq(lessonItems.lessonItemType, LESSON_ITEM_TYPE.text_block.key), -// eq(textBlocks.state, "published"), -// ), -// ) -// .leftJoin( -// files, -// and( -// eq(lessonItems.lessonItemId, files.id), -// eq(lessonItems.lessonItemType, LESSON_ITEM_TYPE.file.key), -// eq(files.state, "published"), -// ), -// ) -// .where(eq(lessonItems.lessonId, lessonId)) -// .orderBy(lessonItems.displayOrder); -// } - -// async getBetaLessons(lessonId: UUIDType) { -// return await this.db -// .select({ -// lessonItemType: lessonItems.lessonItemType, -// lessonItemId: lessonItems.id, -// questionData: questions, -// textBlockData: textBlocks, -// fileData: files, -// displayOrder: lessonItems.displayOrder, -// }) -// .from(lessonItems) -// .leftJoin( -// questions, -// and( -// eq(lessonItems.lessonItemId, questions.id), -// eq(lessonItems.lessonItemType, LESSON_ITEM_TYPE.question.key), -// ), -// ) -// .leftJoin( -// textBlocks, -// and( -// eq(lessonItems.lessonItemId, textBlocks.id), -// eq(lessonItems.lessonItemType, LESSON_ITEM_TYPE.text_block.key), -// ), -// ) -// .leftJoin( -// files, -// and( -// eq(lessonItems.lessonItemId, files.id), -// eq(lessonItems.lessonItemType, LESSON_ITEM_TYPE.file.key), -// ), -// ) -// .where(eq(lessonItems.lessonId, lessonId)) -// .orderBy(lessonItems.displayOrder); -// } - -// async getQuestionAnswers(questionId: UUIDType) { -// return await this.db -// .select({ -// id: questionAnswerOptions.id, -// optionText: questionAnswerOptions.optionText, -// position: questionAnswerOptions.position, -// }) -// .from(questionAnswerOptions) -// .where(eq(questionAnswerOptions.questionId, questionId)); -// } - -// async assignLessonToCourse(courseId: UUIDType, lessonId: UUIDType, displayOrder: number) { -// return await this.db.insert(courseLessons).values({ -// courseId, -// lessonId, -// displayOrder, -// }); -// } - -// async updateFreemiumStatus(lessonId: string, isFreemium: boolean) { -// const [updateFreemiumStatus] = await this.db -// .update(courseLessons) -// .set({ -// isFree: isFreemium, -// }) -// .where(eq(courseLessons.lessonId, lessonId)) -// .returning(); - -// return updateFreemiumStatus; -// } - -// async toggleLessonAsFree(courseId: UUIDType, lessonId: UUIDType, isFree: boolean) { -// return await this.db -// .update(courseLessons) -// .set({ isFree }) -// .where(and(eq(courseLessons.lessonId, lessonId), eq(courseLessons.courseId, courseId))) -// .returning(); -// } - -// async createLesson(body: CreateLessonBody, authorId: string) { -// return await this.db -// .insert(lessons) -// .values({ ...body, authorId }) -// .returning(); -// } - -// async updateLesson(id: string, body: UpdateLessonBody) { -// return await this.db.update(lessons).set(body).where(eq(lessons.id, id)).returning(); -// } - -// async getLessonsCount(conditions: any[]) { -// return await this.db -// .select({ totalItems: count() }) -// .from(lessons) -// .where(and(...conditions)); -// } - -// async updateLessonItemsCount(lessonId: string, trx?: PostgresJsDatabase) { -// const dbInstance = trx ?? this.db; - -// return await dbInstance -// .update(lessons) -// .set({ -// itemsCount: sql`( -// SELECT COUNT(*) -// FROM ${lessonItems} -// WHERE ${lessonItems.lessonId} = ${lessons.id} -// )`, -// }) -// .where(eq(lessons.id, lessonId)); -// } -// } diff --git a/apps/api/src/lesson/adminLesson.repository.ts b/apps/api/src/lesson/repositories/adminLesson.repository.ts similarity index 99% rename from apps/api/src/lesson/adminLesson.repository.ts rename to apps/api/src/lesson/repositories/adminLesson.repository.ts index 65e26a56..92641b1a 100644 --- a/apps/api/src/lesson/adminLesson.repository.ts +++ b/apps/api/src/lesson/repositories/adminLesson.repository.ts @@ -14,7 +14,7 @@ import type { CreateQuizLessonBody, UpdateLessonBody, UpdateQuizLessonBody, -} from "./lesson.schema"; +} from "../lesson.schema"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type * as schema from "src/storage/schema"; diff --git a/apps/api/src/lesson/repositories/lesson.repository.ts b/apps/api/src/lesson/repositories/lesson.repository.ts new file mode 100644 index 00000000..db936594 --- /dev/null +++ b/apps/api/src/lesson/repositories/lesson.repository.ts @@ -0,0 +1,244 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { and, eq, sql } from "drizzle-orm"; + +import { DatabasePg, type UUIDType } from "src/common"; +import { chapters, lessons, questions, studentLessonProgress } from "src/storage/schema"; + +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import type { QuestionBody } from "src/lesson/lesson.schema"; +import type * as schema from "src/storage/schema"; + +@Injectable() +export class LessonRepository { + constructor(@Inject("DB") private readonly db: DatabasePg) {} + + async getLesson(id: UUIDType) { + const [lesson] = await this.db.select().from(lessons).where(eq(lessons.id, id)); + return lesson; + } + + async getLessonsByChapterId(chapterId: UUIDType) { + return this.db + .select({ + id: lessons.id, + title: lessons.title, + type: lessons.type, + description: sql`${lessons.description}`, + fileS3Key: sql`${lessons.fileS3Key}`, + fileType: sql`${lessons.fileType}`, + displayOrder: sql`${lessons.displayOrder}`, + questions: sql` + COALESCE( + ( + SELECT json_agg(questions_data) + FROM ( + SELECT + ${questions.id} AS id, + ${questions.title} AS title, + ${questions.description} AS description, + ${questions.type} AS type, + ${questions.photoQuestionType} AS photoQuestionType, + ${questions.photoS3Key} AS photoS3Key, + ${questions.solutionExplanation} AS solutionExplanation, + -- TODO: add display order + FROM ${questions} + WHERE ${lessons.id} = ${questions.lessonId} + ) AS questions_data + ), + '[]'::json + ) + `, + }) + .from(lessons) + .where(eq(lessons.chapterId, chapterId)) + .orderBy(lessons.displayOrder); + } + + // async completeQuiz( + // courseId: UUIDType, + // lessonId: UUIDType, + // userId: UUIDType, + // completedLessonItemCount: number, + // quizScore: number, + // ) { + // return await this.db + // .insert(studentLessonsProgress) + // .values({ + // studentId: userId, + // lessonId: lessonId, + // courseId: courseId, + // quizCompleted: true, + // completedLessonItemCount, + // quizScore, + // }) + // .onConflictDoUpdate({ + // target: [ + // studentLessonsProgress.studentId, + // studentLessonsProgress.lessonId, + // studentLessonsProgress.courseId, + // ], + // set: { + // quizCompleted: true, + // completedLessonItemCount, + // quizScore, + // }, + // }) + // .returning(); + // } + + // async getLastInteractedOrNextLessonItemForUser(userId: UUIDType) { + // const [lastLessonItem] = await this.db + // .select({ + // id: sql`${studentCompletedLessonItems.lessonItemId}`, + // lessonId: sql`${studentCompletedLessonItems.lessonId}`, + // courseId: sql`${studentCompletedLessonItems.courseId}`, + // courseTitle: sql`${courses.title}`, + // courseDescription: sql`${courses.description}`, + // }) + // .from(studentLessonsProgress) + // .leftJoin(studentCompletedLessonItems, and(eq(studentCompletedLessonItems.studentId, userId))) + // .where( + // and( + // eq(studentCompletedLessonItems.studentId, userId), + // eq(studentLessonsProgress.lessonId, studentCompletedLessonItems.lessonId), + // eq(studentLessonsProgress.courseId, studentCompletedLessonItems.courseId), + // isNull(studentLessonsProgress.completedAt), + // ), + // ) + // .leftJoin(courses, eq(studentCompletedLessonItems.courseId, courses.id)) + // .orderBy(desc(studentCompletedLessonItems.updatedAt)) + // .limit(1); + + // return lastLessonItem; + // } + + async getLessonsProgressByCourseId( + courseId: UUIDType, + userId: UUIDType, + trx?: PostgresJsDatabase, + ) { + const dbInstance = trx ?? this.db; + + return await dbInstance + .select({ + lessonId: studentLessonProgress.lessonId, + completedLessonCount: studentLessonProgress.completedQuestionCount, + quizCompleted: studentLessonProgress.completedAt, + quizScore: studentLessonProgress.quizScore, + }) + .from(studentLessonProgress) + .leftJoin(lessons, eq(studentLessonProgress.lessonId, lessons.id)) + .leftJoin(chapters, eq(lessons.chapterId, chapters.id)) + .where(and(eq(chapters.courseId, courseId), eq(studentLessonProgress.studentId, userId))); + } + + // async setCorrectAnswerForStudentAnswer( + // courseId: UUIDType, + // lessonId: UUIDType, + // questionId: UUIDType, + // userId: UUIDType, + // isCorrect: boolean, + // trx?: PostgresJsDatabase, + // ) { + // const dbInstance = trx ?? this.db; + + // return await dbInstance + // .update(studentQuestionAnswers) + // .set({ + // isCorrect, + // }) + // .where( + // and( + // eq(studentQuestionAnswers.studentId, userId), + // eq(studentQuestionAnswers.questionId, questionId), + // eq(studentQuestionAnswers.lessonId, lessonId), + // eq(studentQuestionAnswers.courseId, courseId), + // ), + // ); + // } + + // async retireQuizProgress( + // courseId: UUIDType, + // lessonId: UUIDType, + // userId: UUIDType, + // trx?: PostgresJsDatabase, + // ) { + // const dbInstance = trx ?? this.db; + + // return await dbInstance + // .update(studentLessonsProgress) + // .set({ quizCompleted: false }) + // .where( + // and( + // eq(studentLessonsProgress.studentId, userId), + // eq(studentLessonsProgress.lessonId, lessonId), + // eq(studentLessonsProgress.courseId, courseId), + // ), + // ); + // } + + // async removeStudentCompletedLessonItems( + // courseId: UUIDType, + // lessonId: UUIDType, + // userId: UUIDType, + // trx?: PostgresJsDatabase, + // ) { + // // TODO: remove this function, not deleting from the database, only clearing variables + // const dbInstance = trx ?? this.db; + + // return await dbInstance + // .delete(studentCompletedLessonItems) + // .where( + // and( + // eq(studentCompletedLessonItems.studentId, userId), + // eq(studentCompletedLessonItems.lessonId, lessonId), + // eq(studentCompletedLessonItems.courseId, courseId), + // ), + // ); + // } + + // async updateStudentLessonProgress(userId: UUIDType, lessonId: UUIDType, courseId: UUIDType) { + // return await this.db + // .update(studentLessonsProgress) + // .set({ + // completedLessonItemCount: sql` + // (SELECT COUNT(*) + // FROM ${studentCompletedLessonItems} + // WHERE ${studentCompletedLessonItems.lessonId} = ${lessonId} + // AND ${studentCompletedLessonItems.courseId} = ${courseId} + // AND ${studentCompletedLessonItems.studentId} = ${userId})`, + // }) + // .where( + // and( + // eq(studentLessonsProgress.courseId, courseId), + // eq(studentLessonsProgress.lessonId, lessonId), + // eq(studentLessonsProgress.studentId, userId), + // ), + // ) + // .returning(); + // } + + // async completeLessonProgress( + // courseId: UUIDType, + // lessonId: UUIDType, + // userId: UUIDType, + // completedAsFreemium: boolean, + // trx?: PostgresJsDatabase, + // ) { + // const dbInstance = trx ?? this.db; + + // return await dbInstance + // .update(studentLessonsProgress) + // .set({ + // completedAt: sql`now()`, + // completedAsFreemium, + // }) + // .where( + // and( + // eq(studentLessonsProgress.courseId, courseId), + // eq(studentLessonsProgress.lessonId, lessonId), + // eq(studentLessonsProgress.studentId, userId), + // ), + // ); + // } +} diff --git a/apps/api/src/lesson/services/adminLesson.service.ts b/apps/api/src/lesson/services/adminLesson.service.ts new file mode 100644 index 00000000..04c5401b --- /dev/null +++ b/apps/api/src/lesson/services/adminLesson.service.ts @@ -0,0 +1,284 @@ +import { BadRequestException, Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { eq, gte, inArray, lte, sql } from "drizzle-orm"; + +import { DatabasePg } from "src/common"; +import { lessons, questionAnswerOptions, questions } from "src/storage/schema"; + +import { LESSON_TYPES } from "../lesson.type"; +import { AdminLessonRepository } from "../repositories/adminLesson.repository"; +import { LessonRepository } from "../repositories/lesson.repository"; + +import type { + CreateLessonBody, + CreateQuizLessonBody, + UpdateLessonBody, + UpdateQuizLessonBody, +} from "../lesson.schema"; +import type { UUIDType } from "src/common"; + +@Injectable() +export class AdminLessonService { + constructor( + @Inject("DB") private readonly db: DatabasePg, + private adminLessonRepository: AdminLessonRepository, + private lessonRepository: LessonRepository, + ) {} + + async createLessonForChapter(data: CreateLessonBody, authorId: UUIDType) { + if ( + (data.type === LESSON_TYPES.PRESENTATION || data.type === LESSON_TYPES.VIDEO) && + (!data.fileS3Key || !data.fileType) + ) { + throw new BadRequestException("File is required for video and presentation lessons"); + } + + const maxDisplayOrder = await this.adminLessonRepository.getMaxDisplayOrder(data.chapterId); + + const lesson = await this.adminLessonRepository.createLessonForChapter( + { ...data, displayOrder: maxDisplayOrder + 1 }, + authorId, + ); + return lesson.id; + } + + async createQuizLesson(data: CreateQuizLessonBody, authorId: UUIDType) { + const maxDisplayOrder = await this.adminLessonRepository.getMaxDisplayOrder(data.chapterId); + + const lesson = await this.createQuizLessonWithQuestionsAndOptions( + data, + authorId, + maxDisplayOrder + 1, + ); + return lesson?.id; + } + + async updateQuizLesson(id: UUIDType, data: UpdateQuizLessonBody, authorId: UUIDType) { + const lesson = await this.lessonRepository.getLesson(id); + + if (!lesson) { + throw new NotFoundException("Lesson not found"); + } + + const updatedLessonId = await this.updateQuizLessonWithQuestionsAndOptions(id, data, authorId); + return updatedLessonId; + } + + async updateLesson(id: string, data: UpdateLessonBody) { + const lesson = await this.lessonRepository.getLesson(id); + + if (!lesson) { + throw new NotFoundException("Lesson not found"); + } + + if ( + (data.type === LESSON_TYPES.PRESENTATION || data.type === LESSON_TYPES.VIDEO) && + (!data.fileS3Key || !data.fileType) + ) { + throw new BadRequestException("File is required for video and presentation lessons"); + } + + const updatedLesson = await this.adminLessonRepository.updateLesson(id, data); + return updatedLesson.id; + } + + async removeLesson(lessonId: UUIDType) { + const [lesson] = await this.adminLessonRepository.getLesson(lessonId); + + if (!lesson) { + throw new NotFoundException("Lesson not found"); + } + + await this.db.transaction(async (trx) => { + await this.adminLessonRepository.removeLesson(lessonId, trx); + await this.adminLessonRepository.updateLessonDisplayOrder(lesson.chapterId, trx); + }); + } + + async updateLessonDisplayOrder(lessonObject: { + lessonId: UUIDType; + displayOrder: number; + }): Promise { + const [lessonToUpdate] = await this.adminLessonRepository.getLesson(lessonObject.lessonId); + + const oldDisplayOrder = lessonToUpdate.displayOrder; + if (!lessonToUpdate || oldDisplayOrder === null) { + throw new NotFoundException("Lesson not found"); + } + + const newDisplayOrder = lessonObject.displayOrder; + + // TODO: extract to repository + await this.db.transaction(async (trx) => { + await trx + .update(lessons) + .set({ + displayOrder: sql`CASE + WHEN ${eq(lessons.id, lessonToUpdate.id)} + THEN ${newDisplayOrder} + WHEN ${newDisplayOrder < oldDisplayOrder} + AND ${gte(lessons.displayOrder, newDisplayOrder)} + AND ${lte(lessons.displayOrder, oldDisplayOrder)} + THEN ${lessons.displayOrder} + 1 + WHEN ${newDisplayOrder > oldDisplayOrder} + AND ${lte(lessons.displayOrder, newDisplayOrder)} + AND ${gte(lessons.displayOrder, oldDisplayOrder)} + THEN ${lessons.displayOrder} - 1 + ELSE ${lessons.displayOrder} + END + `, + }) + .where(eq(lessons.chapterId, lessonToUpdate.chapterId)); + }); + } + + async createQuizLessonWithQuestionsAndOptions( + data: CreateQuizLessonBody, + authorId: UUIDType, + displayOrder: number, + ) { + return await this.db.transaction(async (trx) => { + const lesson = await this.adminLessonRepository.createQuizLessonWithQuestionsAndOptions( + data, + displayOrder, + ); + + if (!data.questions) return; + + const questionsToInsert = data?.questions?.map((question) => ({ + lessonId: lesson.id, + authorId, + type: question.type, + description: question.description || null, + title: question.title, + photoS3Key: question.photoS3Key, + photoQuestionType: question.photoQuestionType || null, + })); + + const insertedQuestions = await trx.insert(questions).values(questionsToInsert).returning(); + + const optionsToInsert = insertedQuestions.flatMap( + (question, index) => + data.questions?.[index].options?.map((option) => ({ + questionId: question.id, + optionText: option.optionText, + isCorrect: option.isCorrect, + position: option.position, + })) || [], + ); + + if (optionsToInsert.length > 0) { + await trx.insert(questionAnswerOptions).values(optionsToInsert); + } + + return lesson; + }); + } + + async updateQuizLessonWithQuestionsAndOptions( + id: UUIDType, + data: UpdateQuizLessonBody, + authorId: UUIDType, + ) { + return await this.db.transaction(async (trx) => { + await this.adminLessonRepository.updateQuizLessonWithQuestionsAndOptions(id, data); + + // TODO: extract to repository + const existingQuestions = await trx + .select({ id: questions.id }) + .from(questions) + .where(eq(questions.lessonId, id)); + + const existingQuestionIds = existingQuestions.map((question) => question.id); + + const inputQuestionIds = data.questions + ? data.questions.map((question) => question.id).filter(Boolean) + : []; + + const questionsToDelete = existingQuestionIds.filter( + (existingId) => !inputQuestionIds.includes(existingId), + ); + + if (questionsToDelete.length > 0) { + // TODO: extract to repository + await trx.delete(questions).where(inArray(questions.id, questionsToDelete)); + await trx + .delete(questionAnswerOptions) + .where(inArray(questionAnswerOptions.questionId, questionsToDelete)); + } + + if (data.questions) { + for (const question of data.questions) { + const questionData = { + type: question.type, + description: question.description || null, + title: question.title, + photoS3Key: question.photoS3Key, + photoQuestionType: question.photoQuestionType || null, + }; + + // TODO: extract to repository + const questionId = + question.id ?? + (( + await trx + .insert(questions) + .values({ + lessonId: id, + authorId, + ...questionData, + }) + .returning() + )[0].id as UUIDType); + + if (question.id) { + await trx.update(questions).set(questionData).where(eq(questions.id, questionId)); + } + + if (question.options) { + // TODO: extract to repository + const existingOptions = await trx + .select({ id: questionAnswerOptions.id }) + .from(questionAnswerOptions) + .where(eq(questionAnswerOptions.questionId, questionId)); + + const existingOptionIds = existingOptions.map((option) => option.id); + const inputOptionIds = question.options.map((option) => option.id).filter(Boolean); + + const optionsToDelete = existingOptionIds.filter( + (existingId) => !inputOptionIds.includes(existingId), + ); + + if (optionsToDelete.length > 0) { + await trx + .delete(questionAnswerOptions) + .where(inArray(questionAnswerOptions.id, optionsToDelete)); + } + + for (const option of question.options) { + const optionData = { + optionText: option.optionText, + isCorrect: option.isCorrect, + position: option.position, + }; + + // TODO: extract to repository + if (option.id) { + await trx + .update(questionAnswerOptions) + .set(optionData) + .where(eq(questionAnswerOptions.id, option.id)); + } else { + await trx.insert(questionAnswerOptions).values({ + questionId, + ...optionData, + }); + } + } + } + } + } + + return id; + }); + } +} diff --git a/apps/api/src/lesson/services/lesson.service.ts b/apps/api/src/lesson/services/lesson.service.ts new file mode 100644 index 00000000..0f2d223c --- /dev/null +++ b/apps/api/src/lesson/services/lesson.service.ts @@ -0,0 +1,349 @@ +import { Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { and, eq, sql } from "drizzle-orm"; + +import { DatabasePg } from "src/common"; +import { FileService } from "src/file/file.service"; +import { + chapters, + lessons, + questionAnswerOptions, + questions, + studentCourses, +} from "src/storage/schema"; + +import { LESSON_TYPES } from "../lesson.type"; + +import type { LessonShow, OptionBody, QuestionBody } from "../lesson.schema"; +import type { LessonTypes, PhotoQuestionType, QuestionType } from "../lesson.type"; +import type { UUIDType } from "src/common"; + +@Injectable() +export class LessonService { + constructor( + @Inject("DB") private readonly db: DatabasePg, + private readonly fileService: FileService, // TODO: add event bus + ) { + // private readonly eventBus: EventBus, + } + + async getLessonById(id: UUIDType): Promise { + const [lesson] = await this.db + .select({ + id: lessons.id, + type: sql`${lessons.type}`, + title: lessons.title, + description: sql`${lessons.description}`, + fileUrl: lessons.fileS3Key, + fileType: lessons.fileType, + displayOrder: sql`${lessons.displayOrder}`, + }) + .from(lessons) + .where(eq(lessons.id, id)); + + if (!lesson) throw new NotFoundException("Lesson not found"); + + if (lesson.type === LESSON_TYPES.TEXT && !lesson.fileUrl) return lesson; + + if (lesson.type !== LESSON_TYPES.QUIZ) { + if (!lesson.fileUrl) throw new NotFoundException("Lesson file not found"); + + if (lesson.fileUrl.startsWith("https://")) return lesson; + + try { + const signedUrl = await this.fileService.getFileUrl(lesson.fileUrl); + return { ...lesson, fileUrl: signedUrl }; + } catch (error) { + console.error(`Failed to get signed URL for ${lesson.fileUrl}:`, error); + throw new NotFoundException("Lesson file not found"); + } + } + + const questionList: QuestionBody[] = await this.db + .select({ + id: questions.id, + type: sql`${questions.type}`, + title: questions.title, + description: sql`${questions.description}`, + photoS3Key: sql`COALESCE(${questions.photoS3Key}, undefined)`, + photoQuestionType: sql`COALESCE( + ${questions.photoQuestionType}, undefined + )`, + options: sql` + COALESCE( + ( + SELECT json_agg(question_options) + FROM ( + SELECT + ${questionAnswerOptions.id} AS id, + ${questionAnswerOptions.optionText} AS optionText, + ${questionAnswerOptions.isCorrect} AS "isCorrect", + ${questionAnswerOptions.position} AS "position", + FROM ${questionAnswerOptions} + WHERE ${questionAnswerOptions.questionId} = ${questions.id} + GROUP BY + ${questionAnswerOptions.id}, + ${questionAnswerOptions.optionText}, + ${questionAnswerOptions.isCorrect}, + ${questionAnswerOptions.position}, + ORDER BY ${questionAnswerOptions.position} + ) AS question_options + ), + undefined + ) + `, + }) + .from(questions) + .where(eq(questions.lessonId, id)); + + const quizDetails = { + questions: questionList, + questionCount: questionList.length, + score: 1, + correctAnswerCount: 1, + wrongAnswerCount: 1, + }; + return { ...lesson, quizDetails }; + } + + // async evaluationQuiz(lessonId: UUIDType, userId: UUIDType) { + // const [accessCourseLessons] = await this.checkLessonAssignment(lessonId, userId); + + // if (!accessCourseLessons.isAssigned && !accessCourseLessons.isFree) + // throw new UnauthorizedException("You don't have assignment to this lesson"); + + // const quizProgress = await this.chapterRepository.getQuizProgress(courseId, lessonId, userId); + + // if (quizProgress?.quizCompleted) throw new ConflictException("Quiz already completed"); + + // const lessonItemsCount = await this.chapterRepository.getLessonItemCount(lessonId); + + // const completedLessonItemsCount = await this.chapterRepository.completedLessonItemsCount( + // courseId, + // lessonId, + // ); + + // if (lessonItemsCount.count !== completedLessonItemsCount.count) + // throw new ConflictException("Lesson is not completed"); + + // const evaluationResult = await this.evaluationsQuestions(courseId, lessonId, userId); + + // if (!evaluationResult) return false; + + // const quizScore = await this.chapterRepository.getQuizScore(courseId, lessonId, userId); + + // const updateQuizResult = await this.chapterRepository.completeQuiz( + // courseId, + // lessonId, + // userId, + // completedLessonItemsCount.count, + // quizScore, + // ); + + // if (!updateQuizResult) return false; + + // return true; + // } + + async checkLessonAssignment(id: UUIDType, userId: UUIDType) { + return this.db + .select({ + isAssigned: sql`CASE WHEN ${studentCourses.id} IS NOT NULL THEN TRUE ELSE FALSE END`, + }) + .from(lessons) + .leftJoin(chapters, eq(lessons.chapterId, chapters.id)) + .leftJoin( + studentCourses, + and(eq(studentCourses.courseId, chapters.courseId), eq(studentCourses.studentId, userId)), + ) + .where(and(eq(chapters.isPublished, true), eq(lessons.id, id))); + } + + // private async evaluationsQuestions(courseId: UUIDType, lessonId: UUIDType, userId: UUIDType) { + // const lesson = await this.chapterRepository.getLessonForUser(courseId, lessonId, userId); + // const questionLessonItems = await this.getLessonQuestionsToEvaluation( + // lesson, + // courseId, + // userId, + // true, + // ); + + // try { + // await this.db.transaction(async (trx) => { + // await Promise.all( + // questionLessonItems.map(async (questionLessonItem) => { + // const answers = await this.chapterRepository.getQuestionAnswers( + // questionLessonItem.content.id, + // userId, + // courseId, + // lessonId, + // lesson.type, + // true, + // trx, + // ); + + // const passQuestion = await match(questionLessonItem.content.questionType) + // .returnType>() + // .with(QUESTION_TYPE.fill_in_the_blanks_text.key, async () => { + // const question = questionLessonItem.content; + // let passQuestion = true; + + // for (const answer of question.questionAnswers) { + // if (answer.optionText != answer.studentAnswerText) { + // passQuestion = false; + // break; + // } + // } + + // return passQuestion; + // }) + // .with(QUESTION_TYPE.fill_in_the_blanks_dnd.key, async () => { + // const question = questionLessonItem.content; + // let passQuestion = true; + + // for (const answer of question.questionAnswers) { + // if (answer.isStudentAnswer != answer.isCorrect) { + // passQuestion = false; + // break; + // } + // } + + // return passQuestion; + // }) + // .otherwise(async () => { + // let passQuestion = true; + // for (const answer of answers) { + // if ( + // answer.isStudentAnswer !== answer.isCorrect || + // isNull(answer.isStudentAnswer) + // ) { + // passQuestion = false; + // break; + // } + // } + + // return passQuestion; + // }); + + // await this.chapterRepository.setCorrectAnswerForStudentAnswer( + // courseId, + // lessonId, + // questionLessonItem.content.id, + // userId, + // passQuestion, + // trx, + // ); + // }), + // ); + // }); + + // const correctAnswers = await this.chapterRepository.getQuizQuestionsAnswers( + // courseId, + // lessonId, + // userId, + // true, + // ); + // const correctAnswerCount = correctAnswers.length; + // const totalQuestions = questionLessonItems.length; + // const wrongAnswerCount = totalQuestions - correctAnswerCount; + // const score = Math.round((correctAnswerCount / totalQuestions) * 100); + + // this.eventBus.publish( + // new QuizCompletedEvent( + // userId, + // courseId, + // lessonId, + // correctAnswerCount, + // wrongAnswerCount, + // score, + // ), + // ); + + // return true; + // } catch (error) { + // console.log("error", error); + // return false; + // } + // } + + // async clearQuizProgress(courseId: UUIDType, lessonId: UUIDType, userId: UUIDType) { + // const [accessCourseLessons] = await this.chapterRepository.checkLessonAssignment( + // courseId, + // lessonId, + // userId, + // ); + + // if (!accessCourseLessons) + // throw new UnauthorizedException("You don't have assignment to this lesson"); + + // const quizProgress = await this.chapterRepository.lessonProgress( + // courseId, + // lessonId, + // userId, + // true, + // ); + + // if (!quizProgress) throw new NotFoundException("Lesson progress not found"); + + // try { + // return await this.db.transaction(async (trx) => { + // const questionIds = await this.chapterRepository.getQuestionsIdsByLessonId(lessonId); + + // await this.chapterRepository.retireQuizProgress(courseId, lessonId, userId, trx); + + // await this.chapterRepository.removeQuestionsAnswer( + // courseId, + // lessonId, + // questionIds, + // userId, + // trx, + // ); + + // await this.chapterRepository.removeStudentCompletedLessonItems( + // courseId, + // lessonId, + // userId, + // trx, + // ); + + // return true; + // }); + // } catch (error) { + // return false; + // } + // } + + // private async getLessonQuestionsToEvaluation( + // lesson: Lesson, + // courseId: UUIDType, + // userId: UUIDType, + // quizCompleted: boolean, + // ) { + // const lessonItemsList = await this.chapterRepository.getLessonItems(lesson.id, courseId); + // const validLessonItemsList = lessonItemsList.filter(this.isValidItem); + + // return await Promise.all( + // validLessonItemsList.map(async (item) => { + // const { lessonItemId, questionData, lessonItemType, displayOrder } = item; + + // if (isNull(questionData)) throw new Error("Question not found"); + + // const content = await this.processQuestionItem( + // { lessonItemId, displayOrder, lessonItemType, questionData }, + // userId, + // courseId, + // lesson.id, + // lesson.type, + // quizCompleted, + // null, + // ); + + // return { + // lessonItemId: item.lessonItemId, + // lessonItemType: item.lessonItemType, + // displayOrder: item.displayOrder, + // content, + // }; + // }), + // ); + // } +} diff --git a/apps/api/src/scorm/services/scorm.service.ts b/apps/api/src/scorm/services/scorm.service.ts index 41b96a91..438454d6 100644 --- a/apps/api/src/scorm/services/scorm.service.ts +++ b/apps/api/src/scorm/services/scorm.service.ts @@ -10,8 +10,8 @@ import xml2js from "xml2js"; import { AdminChapterService } from "src/chapter/adminChapter.service"; import { DatabasePg } from "src/common"; import { FileService } from "src/file/file.service"; -import { AdminLessonService } from "src/lesson/adminLesson.service"; import { LESSON_TYPES } from "src/lesson/lesson.type"; +import { AdminLessonService } from "src/lesson/services/adminLesson.service"; import { S3Service } from "src/s3/s3.service"; import { SCORM } from "../constants/scorm.consts"; @@ -478,10 +478,10 @@ export class ScormService { const extension = path.extname(href).toLowerCase(); return match(extension) - .with(".mp4", ".webm", () => LESSON_TYPES.video) - .with(".pptx", ".ppt", () => LESSON_TYPES.presentation) - .with(".html", () => LESSON_TYPES.textBlock) - .otherwise(() => LESSON_TYPES.file); + .with(".mp4", ".webm", () => LESSON_TYPES.VIDEO) + .with(".pptx", ".ppt", () => LESSON_TYPES.PRESENTATION) + .with(".html", () => LESSON_TYPES.TEXT) + .otherwise(() => LESSON_TYPES.FILE); } /** diff --git a/apps/api/src/seed/e2e-data-seeds.ts b/apps/api/src/seed/e2e-data-seeds.ts index 2ece722e..4bfc2c67 100644 --- a/apps/api/src/seed/e2e-data-seeds.ts +++ b/apps/api/src/seed/e2e-data-seeds.ts @@ -18,7 +18,7 @@ export const e2eCourses: NiceCourseData[] = [ displayOrder: 1, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "E2E Testing Text Block", description: "E2E Testing Text Block Body", displayOrder: 1, @@ -32,7 +32,7 @@ export const e2eCourses: NiceCourseData[] = [ displayOrder: 2, lessons: [ { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "E2E Testing Quiz", description: "E2E Testing Quiz Description", displayOrder: 1, diff --git a/apps/api/src/seed/nice-data-seeds.ts b/apps/api/src/seed/nice-data-seeds.ts index aa445fd1..fc76999e 100644 --- a/apps/api/src/seed/nice-data-seeds.ts +++ b/apps/api/src/seed/nice-data-seeds.ts @@ -21,14 +21,14 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 1, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Introduction to HTML", description: "HTML (HyperText Markup Language) is the standard language used to create the structure of web pages. In this lesson, you'll explore basic HTML elements and how they are used to build the framework of a website.", displayOrder: 1, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "HTML Quiz: Importance of HTML", description: "Why is HTML considered the backbone of any website?", displayOrder: 2, @@ -41,14 +41,14 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.video, + type: LESSON_TYPES.VIDEO, title: "HTML Elements Video", description: "Learn the basics of web development with HTML! Master the structure and tags needed to build professional websites from scratch.", displayOrder: 3, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "CSS and Layout Quiz", description: "In CSS, [word] is used to style the layout, while [word] is used to change colors.", @@ -86,14 +86,14 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.presentation, + type: LESSON_TYPES.PRESENTATION, title: "HTML Hyperlinks Presentation", description: "Learn the basics of web development with HTML! Master the structure and tags needed to build professional websites from scratch.", displayOrder: 5, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "HTML Tag Quiz", description: "Which HTML tag is used to create a hyperlink?", displayOrder: 6, @@ -135,7 +135,7 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 2, lessons: [ { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "HTML Basics: Test Your Knowledge", description: "This lesson is designed to test your understanding of basic HTML concepts. You'll encounter a mix of multiple-choice and single-answer questions to evaluate your knowledge of HTML structure and common elements.", @@ -362,7 +362,7 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 3, lessons: [ { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "CSS Fundamentals: Put Your Skills to the Test", description: "This lesson is a comprehensive quiz to evaluate your understanding of CSS fundamentals. You'll face a variety of question types covering selectors, properties, and layout techniques.", @@ -486,14 +486,14 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 4, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Introduction to HTML", description: "HTML (HyperText Markup Language) is the standard language used to create the structure of web pages. In this lesson, you'll explore basic HTML elements and how they are used to build the framework of a website.", displayOrder: 1, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "HTML Quiz: Importance of HTML", description: "Why is HTML considered the backbone of any website?", displayOrder: 2, @@ -506,14 +506,14 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.video, + type: LESSON_TYPES.VIDEO, title: "HTML Elements Video", description: "Learn the basics of web development with HTML! Master the structure and tags needed to build professional websites from scratch.", displayOrder: 3, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "CSS and Layout Quiz", description: "In CSS, [word] is used to style the layout, while [word] is used to change colors.", @@ -571,14 +571,14 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 1, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Introduction to Java for Android", description: "Java is the primary language used for Android app development. In this lesson, you'll learn about Java syntax, data types, and object-oriented programming principles that form the foundation of Android development.", displayOrder: 1, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Explain why Java is the preferred language for Android development", description: "", displayOrder: 2, @@ -591,13 +591,13 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.video, + type: LESSON_TYPES.VIDEO, title: "Java Basics Video Tutorial", description: "Learn Java basics for Android development.", displayOrder: 3, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "In Java, [word] are used to define the blueprint of objects, while [word] are instances.", description: "", @@ -625,7 +625,7 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "In Android dev, [word] are used to define the user interface, while [word] handle user interactions", description: "", @@ -653,13 +653,13 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.presentation, + type: LESSON_TYPES.PRESENTATION, title: "Java OOP Concepts Presentation", description: "Explore Object-Oriented Programming principles in Java.", displayOrder: 6, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Which keyword is used to create a new instance of a class in Java?", description: "", displayOrder: 7, @@ -701,7 +701,7 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 2, lessons: [ { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Which of the following is the entry point of an Android application?", description: "", displayOrder: 1, @@ -754,21 +754,21 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 1, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Introduction to Kotlin for Android", description: "Kotlin is a modern, concise language used for Android development. In this lesson, you'll learn about Kotlin syntax and basic concepts for creating Android apps.", displayOrder: 1, }, { - type: LESSON_TYPES.video, + type: LESSON_TYPES.VIDEO, title: "Kotlin Basics Video Tutorial", description: "A video tutorial to help you learn Kotlin syntax, object-oriented principles, and how to apply them to Android development.", displayOrder: 2, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Which keyword is used to declare a variable in Kotlin?", description: "", displayOrder: 3, @@ -794,20 +794,20 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 2, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Setting Up Your Android Studio Environment", description: "Learn how to configure Android Studio for Kotlin development and create your first Android project.", displayOrder: 1, }, { - type: LESSON_TYPES.presentation, + type: LESSON_TYPES.PRESENTATION, title: "Creating a Simple Kotlin App", description: "A step-by-step guide to building your first Android app using Kotlin.", displayOrder: 2, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "In Kotlin, [word] are immutable variables, while [word] are mutable variables.", description: "", displayOrder: 3, @@ -845,14 +845,14 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 1, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Introduction to Arithmetic", description: "Arithmetic is the foundation of mathematics. In this lesson, you'll learn about numbers, basic operations, and their properties.", displayOrder: 1, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Why is arithmetic considered the foundation of mathematics? ", description: "", displayOrder: 2, @@ -865,14 +865,14 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.video, + type: LESSON_TYPES.VIDEO, title: "Basic Arithmetic Video Tutorial", description: "Learn the basics of arithmetic operations and how to use them in problem-solving scenarios.", displayOrder: 3, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "In arithmetic, [word] is the result of addition, while [word] is the result of subtraction.", description: "", @@ -900,21 +900,21 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 2, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Understanding Geometry", description: "Geometry involves the study of shapes, sizes, and the properties of space. In this lesson, you'll learn about basic geometric figures and their properties.", displayOrder: 1, }, { - type: LESSON_TYPES.presentation, + type: LESSON_TYPES.PRESENTATION, title: "Geometric Shapes Presentation", description: "Explore various geometric shapes, their formulas for area and perimeter, and their real-life applications.", displayOrder: 2, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Which formula is used to calculate the area of a rectangle?", description: "", displayOrder: 3, @@ -940,14 +940,14 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 3, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Getting Started with Algebra", description: "Algebra helps us solve problems by finding unknown values. In this lesson, you'll learn about variables, expressions, and simple equations.", displayOrder: 1, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "In algebra, [word] represent unknown values, while [word] are mathematical phrases", description: "", @@ -967,7 +967,7 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.video, + type: LESSON_TYPES.VIDEO, title: "Basic Algebra Video Guide", description: "Learn to solve basic algebraic equations and understand how to work with variables.", @@ -982,7 +982,7 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 4, lessons: [ { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Mathematics Basics Quiz: Test Your Knowledge", description: "Evaluate your understanding of arithmetic, geometry, and algebra with this comprehensive quiz.", @@ -1042,21 +1042,21 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 1, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Introduction to English Grammar", description: "Learn the essential grammar rules that form the backbone of English communication, covering nouns, verbs, adjectives, and more.", displayOrder: 1, }, { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Sentence Structure Basics", description: "Explore how sentences are structured, including subject-verb agreement and word order in affirmative, negative, and question forms.", displayOrder: 2, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Explain the difference between a noun and a verb in a sentence.", description: "Explain the difference between a noun and a verb in a sentence.", displayOrder: 3, @@ -1069,14 +1069,14 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.video, + type: LESSON_TYPES.VIDEO, title: "Grammar Rules Video Tutorial", description: "Watch this tutorial to get a comprehensive overview of essential English grammar rules.", displayOrder: 4, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Fill in the blanks: 'She [word] to the store yesterday.'", description: "Fill in the blanks with the correct verb.", displayOrder: 5, @@ -1101,27 +1101,27 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 2, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Common English Words and Phrases", description: "A beginner-friendly list of common English words and phrases you can use in daily conversations.", displayOrder: 1, }, { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Synonyms and Antonyms", description: "Learn about the importance of synonyms and antonyms in expanding your vocabulary and making your speech more varied.", displayOrder: 2, }, { - type: LESSON_TYPES.presentation, + type: LESSON_TYPES.PRESENTATION, title: "English Vocabulary Expansion Presentation", description: "A comprehensive slide presentation on expanding your vocabulary.", displayOrder: 3, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Which word is the synonym of 'happy'?", description: "Choose the correct synonym for 'happy'.", displayOrder: 4, @@ -1138,7 +1138,7 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "I [word] to the park every day.", description: "Fill in the blank with the correct verb.", displayOrder: 5, @@ -1162,21 +1162,21 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 3, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Essential Pronunciation Tips", description: "Learn how to pronounce English words correctly and improve your accent with practical tips and exercises.", displayOrder: 1, }, { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Common Pronunciation Mistakes", description: "Identify and work on common pronunciation challenges for non-native English speakers.", displayOrder: 2, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Which of the following sounds is most commonly mispronounced by non-native English speakers?", description: "Choose the sound that is most commonly mispronounced.", @@ -1195,13 +1195,13 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.video, + type: LESSON_TYPES.VIDEO, title: "Pronunciation and Accent Video Tutorial", description: "A step-by-step video guide on mastering English pronunciation.", displayOrder: 4, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "I love [word] (swimming/swim).", description: "Choose the correct verb form.", displayOrder: 5, @@ -1223,7 +1223,7 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 4, lessons: [ { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Which part of speech is the word 'quickly' in the sentence 'She ran quickly to the store'?", description: "Choose the correct part of speech.", @@ -1242,7 +1242,7 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "She [word] to the park every day.", description: "Fill in the blank with the correct verb.", displayOrder: 2, @@ -1259,7 +1259,7 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "What is the plural form of 'child'?", description: "Choose the correct plural form of 'child'.", displayOrder: 3, @@ -1275,7 +1275,7 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Which of these words is a conjunction?", description: "Choose the correct conjunction.", displayOrder: 4, @@ -1310,34 +1310,34 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 1, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Complex Sentences and Their Use", description: "Learn how to form and use complex sentences to convey more detailed thoughts and ideas effectively.", displayOrder: 1, }, { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Relative Clauses and Modifiers", description: "A deep dive into relative clauses and modifiers, which help to add extra information to sentences.", displayOrder: 2, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "What is the difference between a relative clause and a noun clause?", description: "Explain the difference between relative and noun clauses.", displayOrder: 3, }, { - type: LESSON_TYPES.video, + type: LESSON_TYPES.VIDEO, title: "Advanced Grammar Video Tutorial", description: "Watch this in-depth video to understand complex sentence structures and advanced grammar.", displayOrder: 4, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Fill in the blanks: The book [word] I borrowed yesterday was fascinating.", description: "Fill in the blank with the correct word.", displayOrder: 5, @@ -1361,28 +1361,28 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 2, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Academic Vocabulary and Its Application", description: "Master vocabulary words commonly used in academic papers, essays, and formal discussions.", displayOrder: 1, }, { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Using Formal Language in Communication", description: "Learn how to adjust your language for formal situations, such as presentations or professional meetings.", displayOrder: 2, }, { - type: LESSON_TYPES.presentation, + type: LESSON_TYPES.PRESENTATION, title: "Academic Vocabulary List", description: "Download this list of academic vocabulary and explore their meanings and usage in context.", displayOrder: 3, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "Which word is an example of academic vocabulary?", description: "Select the correct academic word.", displayOrder: 4, @@ -1399,7 +1399,7 @@ export const niceCourses: NiceCourseData[] = [ ], }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "The results [word] the hypothesis.", description: "Fill in the blank with the correct word.", displayOrder: 5, @@ -1420,34 +1420,34 @@ export const niceCourses: NiceCourseData[] = [ displayOrder: 3, lessons: [ { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Understanding Idioms in Context", description: "Learn how idiomatic expressions are used in everyday conversations to sound more natural and fluent.", displayOrder: 1, }, { - type: LESSON_TYPES.textBlock, + type: LESSON_TYPES.TEXT, title: "Common Idioms and Their Meanings", description: "A list of frequently used idioms, their meanings, and examples of how to use them.", displayOrder: 2, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "What does the idiom 'break the ice' mean?", description: "Explain the meaning of the idiom 'break the ice'.", displayOrder: 3, }, { - type: LESSON_TYPES.video, + type: LESSON_TYPES.VIDEO, title: "Idiomatic Expressions Video Tutorial", description: "Watch this video to learn how to use idiomatic expressions in real conversations.", displayOrder: 4, }, { - type: LESSON_TYPES.quiz, + type: LESSON_TYPES.QUIZ, title: "She was [word] when she heard the good news.", description: "Fill in the blank with the correct idiom.", displayOrder: 5, @@ -1464,178 +1464,160 @@ export const niceCourses: NiceCourseData[] = [ }, ], }, - - // { - // type: LESSON_TYPE.multimedia.key, - // title: "Advanced Writing Skills: Crafting Cohesive Paragraphs", - // description: - // "Learn how to write complex, well-structured paragraphs that convey your ideas clearly and persuasively in advanced writing contexts.", - // state: STATUS.published.key, - // imageUrl: faker.image.urlPicsumPhotos(), - // isFree: false, - // items: [ - // { - // itemType: LESSON_ITEM_TYPE.text_block.key, - // title: "Topic Sentences and Supporting Details", - // body: "Learn how to craft a clear topic sentence and use supporting details effectively in your writing.", - // state: STATUS.published.key, - // }, - // { - // itemType: LESSON_ITEM_TYPE.text_block.key, - // title: "Transitions and Coherence in Writing", - // body: "Understand the importance of transitions and coherence to make your paragraphs flow logically.", - // state: STATUS.published.key, - // }, - // { - // itemType: LESSON_ITEM_TYPE.file.key, - // title: "Paragraph Writing Practice", - // type: LESSON_FILE_TYPE.external_presentation.key, - // state: STATUS.published.key, - // body: "Download this practice worksheet to improve your paragraph writing skills.", - // }, - // { - // itemType: LESSON_ITEM_TYPE.question.key, - // questionType: QUESTION_TYPE.fill_in_the_blanks_text.key, - // title: - // "The introduction [word] should [word] the main points [word] in the essay.", - // state: STATUS.published.key, - // solutionExplanation: - // "The introduction paragraph should outline the main points discussed in the essay.", - // questionAnswers: [ - // { - // optionText: "paragraph", - // isCorrect: true, - // position: 0, - // }, - // { - // optionText: "outline", - // isCorrect: true, - // position: 1, - // }, - // { - // optionText: "discussed", - // isCorrect: true, - // position: 2, - // }, - // ], - // }, - // ], - // }, - // { - // type: LESSON_TYPE.multimedia.key, - // title: "Public Speaking: Delivering a Persuasive Speech", - // description: - // "Develop your public speaking skills by learning how to structure and deliver a persuasive speech that captivates your audience.", - // state: STATUS.published.key, - // imageUrl: faker.image.urlPicsumPhotos(), - // isFree: false, - // items: [ - // { - // itemType: LESSON_ITEM_TYPE.text_block.key, - // title: "Structuring a Persuasive Speech", - // body: "Learn the key components of a persuasive speech, including introduction, body, and conclusion.", - // state: STATUS.published.key, - // }, - // { - // itemType: LESSON_ITEM_TYPE.text_block.key, - // title: "Techniques for Engaging Your Audience", - // body: "Discover techniques such as storytelling, rhetorical questions, and powerful language to keep your audience engaged.", - // state: STATUS.published.key, - // }, - // { - // itemType: LESSON_ITEM_TYPE.question.key, - // questionType: QUESTION_TYPE.single_choice.key, - // title: "What is the purpose of the conclusion in a persuasive speech?", - // state: STATUS.published.key, - // questionAnswers: [ - // { - // optionText: "Summarize the main points", - // isCorrect: true, - // position: 0, - // }, - // { - // optionText: "Introduce new information", - // isCorrect: false, - // position: 1, - // }, - // ], - // }, - // { - // itemType: LESSON_ITEM_TYPE.file.key, - // title: "Persuasive Speech Example", - // type: LESSON_FILE_TYPE.external_video.key, - // state: STATUS.published.key, - // body: "Listen to this persuasive speech example to see effective techniques in action.", - // }, - // ], - // }, - // { - // type: LESSON_TYPE.quiz.key, - // title: "Advanced English Quiz: Test Your Knowledge", - // description: - // "Test your mastery of advanced English skills, including grammar, vocabulary, idioms, writing, and public speaking.", - // state: STATUS.published.key, - // imageUrl: faker.image.urlPicsumPhotos(), - // isFree: false, - // items: [ - // { - // itemType: LESSON_ITEM_TYPE.question.key, - // questionType: QUESTION_TYPE.single_choice.key, - // title: "Which sentence is an example of a complex sentence?", - // state: STATUS.published.key, - // questionAnswers: [ - // { - // optionText: "She went to the store, and he stayed home.", - // isCorrect: false, - // position: 0, - // }, - // { - // optionText: "Although it was raining, she went for a walk.", - // isCorrect: true, - // position: 1, - // }, - // ], - // }, - // { - // itemType: LESSON_ITEM_TYPE.question.key, - // questionType: QUESTION_TYPE.single_choice.key, - // title: "Which idiom means 'to be very happy'?", - // state: STATUS.published.key, - // questionAnswers: [ - // { - // optionText: "On cloud nine", - // isCorrect: true, - // position: 0, - // }, - // { - // optionText: "Hit the nail on the head", - // isCorrect: false, - // position: 1, - // }, - // ], - // }, - // { - // itemType: LESSON_ITEM_TYPE.question.key, - // questionType: QUESTION_TYPE.fill_in_the_blanks_text.key, - // title: "The manager will [word] the team meeting [word].", - // state: STATUS.published.key, - // solutionExplanation: - // "The manager will lead the team meeting tomorrow.", - // questionAnswers: [ - // { - // optionText: "lead", - // isCorrect: true, - // position: 0, - // }, - // { - // optionText: "tomorrow", - // isCorrect: true, - // position: 1, - // }, - // ], - // }, - // ], - // }, - // ], - // }, + { + title: "Artificial Intelligence in Business: Fundamentals", + description: + "This beginner-friendly course introduces the basics of AI in business. Learn about key concepts, terminologies, and how AI is applied to improve efficiency, automate processes, and enhance decision-making in various industries. By the end, you'll understand AI's potential to transform your business.", + isPublished: true, + priceInCents: 12900, + category: "Artificial Intelligence", + thumbnailS3Key: faker.image.urlPicsumPhotos(), + chapters: [ + { + title: "Understanding AI Basics", + isPublished: true, + isFreemium: false, + displayOrder: 1, + lessons: [ + { + type: LESSON_TYPES.VIDEO, + title: "What is Artificial Intelligence? An Introductory Overview", + description: + "A comprehensive video introduction to the concept of Artificial Intelligence, its history, and its significance in business.", + displayOrder: 1, + }, + { + type: LESSON_TYPES.TEXT, + title: "Key Concepts and Terminologies in AI", + description: + '

Artificial Intelligence (AI) refers to the simulation of human intelligence in machines programmed to think, learn, and make decisions. Below are some key concepts and terminologies essential to understanding AI:

  • Machine Learning (ML): A subset of AI focused on creating algorithms that allow computers to learn from and make predictions based on data. Example: A recommendation system suggesting movies based on your viewing history.
  • Neural Networks: Inspired by the human brain, these are algorithms designed to recognize patterns and process data in layers, enabling tasks like image and speech recognition.
  • Natural Language Processing (NLP): This involves teaching machines to understand, interpret, and generate human language. Example: Virtual assistants like Alexa or Siri.
  • Computer Vision: A field of AI that enables computers to interpret and process visual data, such as images and videos. Example: Facial recognition technology.
  • Deep Learning: A more complex subset of ML that uses large neural networks to analyze massive amounts of data and solve intricate problems, such as self-driving cars.
  • Supervised vs. Unsupervised Learning:
    - Supervised Learning: The AI is trained on labeled data (e.g., images labeled as "cat" or "dog").
    - Unsupervised Learning: The AI identifies patterns in unlabeled data without explicit instructions.
  • Big Data: The large volume of structured and unstructured data generated by businesses and devices, which is essential for training AI models.
  • Automation: AI is often used to automate repetitive tasks, freeing up human resources for more complex activities.
  • Ethics in AI: As AI becomes more powerful, ensuring its ethical use (e.g., avoiding bias in decision-making) is critical for building trust.

Why These Concepts Matter

Understanding these basic AI terms is the first step toward recognizing how AI can be applied in business. Each concept represents a building block of AI\'s potential to transform industries by increasing efficiency, improving decision-making, and creating innovative solutions.

', + displayOrder: 2, + }, + { + type: LESSON_TYPES.PRESENTATION, + title: "AI Applications Across Industries", + description: + "A presentation exploring real-world AI applications in sectors like healthcare, finance, and retail.", + displayOrder: 3, + }, + { + type: LESSON_TYPES.QUIZ, + title: "AI Quiz: Primary Goal of AI in Business", + description: + "Test your understanding of the fundamental goal of AI in business applications.", + displayOrder: 4, + questions: [ + { + type: QuestionType.SingleChoice, + title: "What is the primary goal of AI in business?", + options: [ + { optionText: "Replace human workers", isCorrect: false, position: 0 }, + { optionText: "Automate repetitive tasks", isCorrect: false, position: 1 }, + { optionText: "Improve decision-making", isCorrect: true, position: 2 }, + { optionText: "Eliminate operational costs", isCorrect: false, position: 3 }, + ], + }, + ], + }, + { + type: LESSON_TYPES.QUIZ, + title: "AI Quiz: Applications of AI", + description: "Identify common AI applications in various business domains.", + displayOrder: 5, + questions: [ + { + type: QuestionType.MultipleChoice, + title: + "Which of the following are applications of AI in business? (Select all that apply)", + options: [ + { optionText: "Customer service chatbots", isCorrect: true, position: 0 }, + { optionText: "Predictive analytics", isCorrect: true, position: 1 }, + { optionText: "Supply chain optimization", isCorrect: true, position: 2 }, + { optionText: "Space exploration tools", isCorrect: false, position: 3 }, + ], + }, + ], + }, + { + type: LESSON_TYPES.QUIZ, + title: "AI Quiz: Can AI Function Without Data?", + description: "Test your understanding of AI's reliance on data.", + displayOrder: 6, + questions: [ + { + type: QuestionType.TrueOrFalse, + title: "AI can function without any data input from humans.", + options: [ + { optionText: "True", isCorrect: false, position: 0 }, + { optionText: "False", isCorrect: true, position: 1 }, + ], + }, + ], + }, + { + type: LESSON_TYPES.QUIZ, + title: "Photo Identification: AI Solutions", + description: "Identify the AI-driven solution from the provided images.", + displayOrder: 7, + questions: [ + { + type: QuestionType.PhotoQuestion, + title: "Which image represents an AI-driven chatbot?", + options: [ + { optionText: "Image 1 (Chatbot Interface)", isCorrect: true, position: 0 }, + { optionText: "Image 2 (Calculator)", isCorrect: false, position: 1 }, + { optionText: "Image 3 (Spreadsheet)", isCorrect: false, position: 2 }, + ], + }, + ], + }, + { + type: LESSON_TYPES.QUIZ, + title: "AI Fill in the Blanks", + description: "Complete the sentences with the correct AI-related terms.", + displayOrder: 8, + questions: [ + { + type: QuestionType.FillInTheBlanks, + title: + "Complete the blanks: Artificial [word] refers to the ability of machines to mimic [word] intelligence.", + options: [ + { optionText: "Intelligence", isCorrect: true, position: 0 }, + { optionText: "Automation", isCorrect: false, position: 1 }, + { optionText: "Learning", isCorrect: false, position: 2 }, + { optionText: "Human", isCorrect: true, position: 3 }, + { optionText: "Animal", isCorrect: false, position: 4 }, + ], + }, + ], + }, + { + type: LESSON_TYPES.QUIZ, + title: "Brief Response: Why Businesses Adopt AI", + description: "Explain in one sentence why businesses adopt AI.", + displayOrder: 9, + questions: [ + { + type: QuestionType.BriefResponse, + title: "In one sentence, explain why businesses are adopting AI.", + }, + ], + }, + { + type: LESSON_TYPES.QUIZ, + title: "Detailed Response: AI's Role in an Industry", + description: "Describe how AI can improve decision-making in a specific industry.", + displayOrder: 10, + questions: [ + { + type: QuestionType.DetailedResponse, + title: + "Describe how AI can improve decision-making in a specific industry of your choice.", + }, + ], + }, + ], + }, + ], + }, ]; diff --git a/apps/api/src/seed/seed-helpers.ts b/apps/api/src/seed/seed-helpers.ts index 94a7044a..514ca02c 100644 --- a/apps/api/src/seed/seed-helpers.ts +++ b/apps/api/src/seed/seed-helpers.ts @@ -92,9 +92,9 @@ export async function createNiceCourses( displayOrder: index + 1, fileS3Key: getFileUrl(lessonData.type), fileType: - lessonData.type === LESSON_TYPES.presentation + lessonData.type === LESSON_TYPES.PRESENTATION ? "pptx" - : lessonData.type === LESSON_TYPES.video + : lessonData.type === LESSON_TYPES.VIDEO ? "mp4" : null, chapterId: chapter.id, @@ -103,7 +103,7 @@ export async function createNiceCourses( }) .returning(); - if (lessonData.type === LESSON_TYPES.quiz && lessonData.questions) { + if (lessonData.type === LESSON_TYPES.QUIZ && lessonData.questions) { for (const [index, questionData] of lessonData.questions.entries()) { const questionId = crypto.randomUUID(); // TODO: add displayOrder to questions @@ -186,9 +186,9 @@ const external_presentation_urls = [ ]; function getFileUrl(lessonType: string) { - if (lessonType === LESSON_TYPES.video) { + if (lessonType === LESSON_TYPES.VIDEO) { return faker.helpers.arrayElement(external_video_urls); - } else if (lessonType === LESSON_TYPES.presentation) { + } else if (lessonType === LESSON_TYPES.PRESENTATION) { return faker.helpers.arrayElement(external_presentation_urls); } return null; diff --git a/apps/api/src/seed/seed.ts b/apps/api/src/seed/seed.ts index 9b4d75f6..7e93b421 100644 --- a/apps/api/src/seed/seed.ts +++ b/apps/api/src/seed/seed.ts @@ -146,6 +146,7 @@ async function createLessonProgress(userId: string) { const courseLessonsList = await db .select({ lessonId: sql`${lessons.id}`, + chapterId: sql`${chapters.id}`, createdAt: sql`${courses.createdAt}`, lessonType: sql`${lessons.type}`, }) @@ -156,14 +157,13 @@ async function createLessonProgress(userId: string) { .where(eq(studentCourses.studentId, userId)); const lessonProgressList = courseLessonsList.map((courseLesson) => { - const lessonId = courseLesson.lessonId; - return { - lessonId, studentId: userId, + lessonId: courseLesson.lessonId, + chapterId: courseLesson.chapterId, createdAt: courseLesson.createdAt, updatedAt: courseLesson.createdAt, - quizScore: courseLesson.lessonType === LESSON_TYPES.quiz ? 0 : null, + quizScore: courseLesson.lessonType === LESSON_TYPES.QUIZ ? 0 : null, }; }); @@ -191,7 +191,7 @@ async function createQuizAttempts(userId: string) { .innerJoin(chapters, eq(courses.id, chapters.courseId)) .innerJoin(lessons, eq(lessons.chapterId, chapters.id)) .innerJoin(questions, eq(questions.lessonId, lessons.id)) - .where(and(eq(courses.isPublished, true), eq(lessons.type, LESSON_TYPES.quiz))) + .where(and(eq(courses.isPublished, true), eq(lessons.type, LESSON_TYPES.QUIZ))) .groupBy(courses.id, lessons.id); const createdQuizAttempts = quizzes.map((quiz) => { diff --git a/apps/api/src/storage/migrations/0004_add_chapter_id_to_student_lesson_progress.sql b/apps/api/src/storage/migrations/0004_add_chapter_id_to_student_lesson_progress.sql new file mode 100644 index 00000000..05da5520 --- /dev/null +++ b/apps/api/src/storage/migrations/0004_add_chapter_id_to_student_lesson_progress.sql @@ -0,0 +1,9 @@ +ALTER TABLE "student_lesson_progress" DROP CONSTRAINT "student_lesson_progress_student_id_lesson_id_unique";--> statement-breakpoint +ALTER TABLE "student_lesson_progress" ADD COLUMN "chapter_id" uuid;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "student_lesson_progress" ADD CONSTRAINT "student_lesson_progress_chapter_id_chapters_id_fk" FOREIGN KEY ("chapter_id") REFERENCES "public"."chapters"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +ALTER TABLE "student_lesson_progress" ADD CONSTRAINT "student_lesson_progress_student_id_lesson_id_chapter_id_unique" UNIQUE("student_id","lesson_id","chapter_id"); \ No newline at end of file diff --git a/apps/api/src/storage/migrations/meta/0004_snapshot.json b/apps/api/src/storage/migrations/meta/0004_snapshot.json new file mode 100644 index 00000000..e436353a --- /dev/null +++ b/apps/api/src/storage/migrations/meta/0004_snapshot.json @@ -0,0 +1,1868 @@ +{ + "id": "4a61ac2b-fd8b-4d94-a632-e4d19f0c32c8", + "prevId": "a69a4ab9-0250-4505-b38f-f18887d2b8e8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_title_unique": { + "name": "categories_title_unique", + "nullsNotDistinct": false, + "columns": [ + "title" + ] + } + } + }, + "public.chapters": { + "name": "chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_freemium": { + "name": "is_freemium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lesson_count": { + "name": "lesson_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "chapters_course_id_courses_id_fk": { + "name": "chapters_course_id_courses_id_fk", + "tableFrom": "chapters", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chapters_author_id_users_id_fk": { + "name": "chapters_author_id_users_id_fk", + "tableFrom": "chapters", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.course_students_stats": { + "name": "course_students_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "new_students_count": { + "name": "new_students_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "course_students_stats_course_id_courses_id_fk": { + "name": "course_students_stats_course_id_courses_id_fk", + "tableFrom": "course_students_stats", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "course_students_stats_author_id_users_id_fk": { + "name": "course_students_stats_author_id_users_id_fk", + "tableFrom": "course_students_stats", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "course_students_stats_course_id_month_year_unique": { + "name": "course_students_stats_course_id_month_year_unique", + "nullsNotDistinct": false, + "columns": [ + "course_id", + "month", + "year" + ] + } + } + }, + "public.courses": { + "name": "courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "thumbnail_s3_key": { + "name": "thumbnail_s3_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_in_cents": { + "name": "price_in_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "chapter_count": { + "name": "chapter_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_scorm": { + "name": "is_scorm", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "courses_author_id_users_id_fk": { + "name": "courses_author_id_users_id_fk", + "tableFrom": "courses", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "courses_category_id_categories_id_fk": { + "name": "courses_category_id_categories_id_fk", + "tableFrom": "courses", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.courses_summary_stats": { + "name": "courses_summary_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "free_purchased_count": { + "name": "free_purchased_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "paid_purchased_count": { + "name": "paid_purchased_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "paid_purchased_after_freemium_count": { + "name": "paid_purchased_after_freemium_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_freemium_student_count": { + "name": "completed_freemium_student_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_course_student_count": { + "name": "completed_course_student_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "courses_summary_stats_course_id_courses_id_fk": { + "name": "courses_summary_stats_course_id_courses_id_fk", + "tableFrom": "courses_summary_stats", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "courses_summary_stats_author_id_users_id_fk": { + "name": "courses_summary_stats_author_id_users_id_fk", + "tableFrom": "courses_summary_stats", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "courses_summary_stats_course_id_unique": { + "name": "courses_summary_stats_course_id_unique", + "nullsNotDistinct": false, + "columns": [ + "course_id" + ] + } + } + }, + "public.create_tokens": { + "name": "create_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "create_token": { + "name": "create_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "create_tokens_user_id_users_id_fk": { + "name": "create_tokens_user_id_users_id_fk", + "tableFrom": "create_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "credentials_user_id_users_id_fk": { + "name": "credentials_user_id_users_id_fk", + "tableFrom": "credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.lessons": { + "name": "lessons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "chapter_id": { + "name": "chapter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_s3_key": { + "name": "file_s3_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "lessons_chapter_id_chapters_id_fk": { + "name": "lessons_chapter_id_chapters_id_fk", + "tableFrom": "lessons", + "tableTo": "chapters", + "columnsFrom": [ + "chapter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.question_answer_options": { + "name": "question_answer_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "option_text": { + "name": "option_text", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "question_answer_options_question_id_questions_id_fk": { + "name": "question_answer_options_question_id_questions_id_fk", + "tableFrom": "question_answer_options", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.questions": { + "name": "questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "photo_s3_key": { + "name": "photo_s3_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "photo_question_type": { + "name": "photo_question_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "solution_explanation": { + "name": "solution_explanation", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "questions_lesson_id_lessons_id_fk": { + "name": "questions_lesson_id_lessons_id_fk", + "tableFrom": "questions", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "questions_author_id_users_id_fk": { + "name": "questions_author_id_users_id_fk", + "tableFrom": "questions", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.quiz_attempts": { + "name": "quiz_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "correct_answers": { + "name": "correct_answers", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "wrong_answers": { + "name": "wrong_answers", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quiz_attempts_course_id_courses_id_fk": { + "name": "quiz_attempts_course_id_courses_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quiz_attempts_lesson_id_lessons_id_fk": { + "name": "quiz_attempts_lesson_id_lessons_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.reset_tokens": { + "name": "reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reset_token": { + "name": "reset_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "reset_tokens_user_id_users_id_fk": { + "name": "reset_tokens_user_id_users_id_fk", + "tableFrom": "reset_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.scorm_files": { + "name": "scorm_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "s3_key_path": { + "name": "s3_key_path", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.scorm_metadata": { + "name": "scorm_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_point": { + "name": "entry_point", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "s3_key": { + "name": "s3_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "scorm_metadata_course_id_courses_id_fk": { + "name": "scorm_metadata_course_id_courses_id_fk", + "tableFrom": "scorm_metadata", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scorm_metadata_file_id_scorm_files_id_fk": { + "name": "scorm_metadata_file_id_scorm_files_id_fk", + "tableFrom": "scorm_metadata", + "tableTo": "scorm_files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.student_chapter_progress": { + "name": "student_chapter_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chapter_id": { + "name": "chapter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "completed_lesson_count": { + "name": "completed_lesson_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_as_freemium": { + "name": "completed_as_freemium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "student_chapter_progress_student_id_users_id_fk": { + "name": "student_chapter_progress_student_id_users_id_fk", + "tableFrom": "student_chapter_progress", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_chapter_progress_course_id_courses_id_fk": { + "name": "student_chapter_progress_course_id_courses_id_fk", + "tableFrom": "student_chapter_progress", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_chapter_progress_chapter_id_chapters_id_fk": { + "name": "student_chapter_progress_chapter_id_chapters_id_fk", + "tableFrom": "student_chapter_progress", + "tableTo": "chapters", + "columnsFrom": [ + "chapter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_chapter_progress_student_id_course_id_chapter_id_unique": { + "name": "student_chapter_progress_student_id_course_id_chapter_id_unique", + "nullsNotDistinct": false, + "columns": [ + "student_id", + "course_id", + "chapter_id" + ] + } + } + }, + "public.student_courses": { + "name": "student_courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "finished_chapter_count": { + "name": "finished_chapter_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "student_courses_student_id_users_id_fk": { + "name": "student_courses_student_id_users_id_fk", + "tableFrom": "student_courses", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_courses_course_id_courses_id_fk": { + "name": "student_courses_course_id_courses_id_fk", + "tableFrom": "student_courses", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_courses_student_id_course_id_unique": { + "name": "student_courses_student_id_course_id_unique", + "nullsNotDistinct": false, + "columns": [ + "student_id", + "course_id" + ] + } + } + }, + "public.student_lesson_progress": { + "name": "student_lesson_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chapter_id": { + "name": "chapter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "completed_question_count": { + "name": "completed_question_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quiz_score": { + "name": "quiz_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "student_lesson_progress_student_id_users_id_fk": { + "name": "student_lesson_progress_student_id_users_id_fk", + "tableFrom": "student_lesson_progress", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "student_lesson_progress_chapter_id_chapters_id_fk": { + "name": "student_lesson_progress_chapter_id_chapters_id_fk", + "tableFrom": "student_lesson_progress", + "tableTo": "chapters", + "columnsFrom": [ + "chapter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "student_lesson_progress_lesson_id_lessons_id_fk": { + "name": "student_lesson_progress_lesson_id_lessons_id_fk", + "tableFrom": "student_lesson_progress", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_lesson_progress_student_id_lesson_id_chapter_id_unique": { + "name": "student_lesson_progress_student_id_lesson_id_chapter_id_unique", + "nullsNotDistinct": false, + "columns": [ + "student_id", + "lesson_id", + "chapter_id" + ] + } + } + }, + "public.student_question_answers": { + "name": "student_question_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "student_question_answers_question_id_questions_id_fk": { + "name": "student_question_answers_question_id_questions_id_fk", + "tableFrom": "student_question_answers", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "student_question_answers_student_id_users_id_fk": { + "name": "student_question_answers_student_id_users_id_fk", + "tableFrom": "student_question_answers", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_question_answers_question_id_student_id_unique": { + "name": "student_question_answers_question_id_student_id_unique", + "nullsNotDistinct": false, + "columns": [ + "question_id", + "student_id" + ] + } + } + }, + "public.user_details": { + "name": "user_details", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_phone_number": { + "name": "contact_phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_details_user_id_users_id_fk": { + "name": "user_details_user_id_users_id_fk", + "tableFrom": "user_details", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_details_user_id_unique": { + "name": "user_details_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.user_statistics": { + "name": "user_statistics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current_streak": { + "name": "current_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "longest_streak": { + "name": "longest_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_activity_date": { + "name": "last_activity_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activity_history": { + "name": "activity_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_statistics_user_id_users_id_fk": { + "name": "user_statistics_user_id_users_id_fk", + "tableFrom": "user_statistics", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_statistics_user_id_unique": { + "name": "user_statistics_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'student'" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/storage/migrations/meta/_journal.json b/apps/api/src/storage/migrations/meta/_journal.json index 392102d4..00e47a21 100644 --- a/apps/api/src/storage/migrations/meta/_journal.json +++ b/apps/api/src/storage/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1734419608219, "tag": "0003_add_question_missing_columns", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1734960126182, + "tag": "0004_add_chapter_id_to_student_lesson_progress", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/storage/schema/index.ts b/apps/api/src/storage/schema/index.ts index aca4e395..6ea34a74 100644 --- a/apps/api/src/storage/schema/index.ts +++ b/apps/api/src/storage/schema/index.ts @@ -244,6 +244,9 @@ export const studentLessonProgress = pgTable( studentId: uuid("student_id") .references(() => users.id, { onDelete: "set null" }) .notNull(), + chapterId: uuid("chapter_id").references(() => chapters.id, { onDelete: "cascade" }), + // TODO: add notNull after deploy + // .notNull(), lessonId: uuid("lesson_id") .references(() => lessons.id, { onDelete: "cascade" }) .notNull(), @@ -256,7 +259,7 @@ export const studentLessonProgress = pgTable( }), }, (table) => ({ - unq: unique().on(table.studentId, table.lessonId), + unq: unique().on(table.studentId, table.lessonId, table.chapterId), }), ); diff --git a/apps/api/src/stripe/stripeWebhook.handler.ts b/apps/api/src/stripe/stripeWebhook.handler.ts index 5d8f64a2..8c38bbb8 100644 --- a/apps/api/src/stripe/stripeWebhook.handler.ts +++ b/apps/api/src/stripe/stripeWebhook.handler.ts @@ -5,7 +5,7 @@ import { isEmpty } from "lodash"; import Stripe from "stripe"; import { DatabasePg } from "src/common"; -import { LessonRepository } from "src/lesson/lesson.repository"; +import { LessonRepository } from "src/lesson/repositories/lesson.repository"; import { StatisticsRepository } from "src/statistics/repositories/statistics.repository"; import { chapters, diff --git a/apps/api/src/studentLessonProgress/studentLessonProgress.service.ts b/apps/api/src/studentLessonProgress/studentLessonProgress.service.ts index fad7a177..dbbd08dd 100644 --- a/apps/api/src/studentLessonProgress/studentLessonProgress.service.ts +++ b/apps/api/src/studentLessonProgress/studentLessonProgress.service.ts @@ -1,11 +1,11 @@ -import { ConflictException, Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { Inject, Injectable, NotFoundException } from "@nestjs/common"; import { and, eq, isNotNull, sql } from "drizzle-orm"; -import { LESSON_ITEM_TYPE } from "src/chapter/chapter.type"; import { DatabasePg } from "src/common"; import { StatisticsRepository } from "src/statistics/repositories/statistics.repository"; import { chapters, + courses, lessons, studentChapterProgress, studentCourses, @@ -14,6 +14,7 @@ import { import { PROGRESS_STATUSES } from "src/utils/types/progress.type"; import type { UUIDType } from "src/common"; +import type { ProgressStatus } from "src/utils/types/progress.type"; @Injectable() export class StudentLessonProgressService { @@ -28,45 +29,84 @@ export class StudentLessonProgressService { id: lessons.id, type: lessons.type, chapterId: chapters.id, + chapterLessonCount: chapters.lessonCount, courseId: chapters.courseId, }) .from(lessons) .leftJoin(chapters, eq(chapters.id, lessons.chapterId)) .where(and(eq(lessons.id, id))); - if (!lesson || lesson.chapterId === null || lesson.courseId === null) { + if (!lesson || !lesson.chapterId || !lesson.courseId || !lesson.chapterLessonCount) { throw new NotFoundException(`Lesson with id ${id} not found`); } - if (lesson.type === "text_block") { - throw new ConflictException("Text block is not completable"); - } + const [createdLessonProgress] = await this.db + .insert(studentLessonProgress) + .values({ studentId, lessonId: lesson.id, completedAt: sql`now()` }) + .onConflictDoNothing() + .returning(); + + if (!createdLessonProgress) return; + + await this.updateChapterProgress(lesson.chapterId, studentId, lesson.chapterLessonCount); + + await this.checkCourseIsCompletedForUser(lesson.courseId, studentId); + } - const [existingRecord] = await this.db - .select() + private async updateChapterProgress( + chapterId: UUIDType, + studentId: UUIDType, + lessonCount: number, + ) { + const [completedLessonCount] = await this.db + .select({ count: sql`count(*)` }) .from(studentLessonProgress) .where( and( - eq(studentLessonProgress.lessonId, lesson.id), + eq(studentLessonProgress.chapterId, chapterId), eq(studentLessonProgress.studentId, studentId), + isNotNull(studentLessonProgress.completedAt), ), ); - if (existingRecord) return; - - await this.db.insert(studentLessonProgress).values({ studentId, lessonId: lesson.id }); + if (completedLessonCount.count === lessonCount) { + return await this.db + .update(studentChapterProgress) + .set({ + completedLessonCount: completedLessonCount.count, + completedAt: sql`now()`, + }) + .where( + and( + eq(studentChapterProgress.chapterId, chapterId), + eq(studentChapterProgress.studentId, studentId), + ), + ) + .returning(); + } - await this.checkCourseIsCompletedForUser(lesson.courseId, studentId); + return await this.db + .update(studentChapterProgress) + .set({ + completedLessonCount: completedLessonCount.count, + }) + .where( + and( + eq(studentChapterProgress.chapterId, chapterId), + eq(studentChapterProgress.studentId, studentId), + ), + ) + .returning(); } private async checkCourseIsCompletedForUser(courseId: UUIDType, studentId: UUIDType) { - const courseCompleted = await this.getCourseCompletionStatus(courseId); + const courseProgress = await this.getCourseCompletionStatus(courseId, studentId); const courseFinishedChapterCount = await this.getCourseFinishedChapterCount( courseId, studentId, ); - if (courseCompleted.courseCompleted) { + if (courseProgress.courseIsCompleted) { await this.updateStudentCourseStats( studentId, courseId, @@ -77,7 +117,7 @@ export class StudentLessonProgressService { return await this.statisticsRepository.updateCompletedAsFreemiumCoursesStats(courseId); } - if (courseCompleted.state !== PROGRESS_STATUSES.IN_PROGRESS) { + if (courseProgress.progress !== PROGRESS_STATUSES.IN_PROGRESS) { return await this.updateStudentCourseStats( studentId, courseId, @@ -104,71 +144,38 @@ export class StudentLessonProgressService { return finishedLessonsCount.count; } - // TODO: refactor this to use correct new names - private async getCourseCompletionStatus(courseId: UUIDType) { - const [courseCompleted] = await this.db.execute(sql` - WITH lesson_count AS ( - SELECT - course_lessons.lesson_id AS lesson_id, - COUNT(lesson_items.id) AS lesson_count - FROM - course_lessons - LEFT JOIN lesson_items ON course_lessons.lesson_id = lesson_items.lesson_id - WHERE - course_lessons.course_id = ${courseId} - AND lesson_items.lesson_item_type != ${LESSON_ITEM_TYPE.text_block.key} - GROUP BY - course_lessons.lesson_id - ), - completed_lesson_count AS ( - SELECT - course_lessons.lesson_id AS lesson_id, - COUNT(student_completed_lesson_items.id) AS completed_lesson_count - FROM - course_lessons - LEFT JOIN student_completed_lesson_items ON course_lessons.lesson_id = student_completed_lesson_items.lesson_id - WHERE - course_lessons.course_id = ${courseId} - GROUP BY - course_lessons.lesson_id - ), - lesson_completion_status AS ( - SELECT - lc.lesson_id, - CASE - WHEN lc.lesson_count = clc.completed_lesson_count THEN 1 - ELSE 0 - END AS is_lesson_completed - FROM - lesson_count lc - JOIN completed_lesson_count clc ON lc.lesson_id = clc.lesson_id - ), - course_completion_status AS ( - SELECT - CASE - WHEN COUNT(*) = SUM(is_lesson_completed) THEN true - ELSE false - END AS is_course_completed - FROM - lesson_completion_status - ) - SELECT is_course_completed:: BOOLEAN AS "courseCompleted", student_courses.state as "state" - FROM course_completion_status - LEFT JOIN student_courses ON student_courses.course_id = ${courseId} - `); - - return courseCompleted; + private async getCourseCompletionStatus(courseId: UUIDType, studentId: UUIDType) { + const [courseCompletedStatus] = await this.db + .select({ + courseIsCompleted: sql`${studentCourses.finishedChapterCount} = ${courses.chapterCount}`, + progress: sql`${studentCourses.progress}`, + }) + .from(studentCourses) + .leftJoin(courses, and(eq(courses.id, studentCourses.courseId))) + .where(and(eq(studentCourses.courseId, courseId), eq(studentCourses.studentId, studentId))); + + return { + courseIsCompleted: courseCompletedStatus.courseIsCompleted, + progress: courseCompletedStatus.progress, + }; } private async updateStudentCourseStats( studentId: UUIDType, courseId: UUIDType, - progress: string, + progress: ProgressStatus, finishedChapterCount: number, ) { + if (progress === PROGRESS_STATUSES.COMPLETED) { + return await this.db + .update(studentCourses) + .set({ progress, completedAt: sql`now()`, finishedChapterCount }) + .where(and(eq(studentCourses.studentId, studentId), eq(studentCourses.courseId, courseId))); + } + return await this.db .update(studentCourses) - .set({ progress, completedAt: sql`now()`, finishedChapterCount }) + .set({ progress, finishedChapterCount }) .where(and(eq(studentCourses.studentId, studentId), eq(studentCourses.courseId, courseId))); } } diff --git a/apps/api/src/swagger/api-schema.json b/apps/api/src/swagger/api-schema.json index b24c36be..30e282c5 100644 --- a/apps/api/src/swagger/api-schema.json +++ b/apps/api/src/swagger/api-schema.json @@ -1590,6 +1590,32 @@ } } }, + "/api/lesson/{id}": { + "get": { + "operationId": "LessonController_getLessonById", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetLessonByIdResponse" + } + } + } + } + } + } + }, "/api/lesson/beta-create-lesson": { "post": { "operationId": "LessonController_betaCreateLesson", @@ -4211,15 +4237,140 @@ "fileUrl" ] }, + "GetLessonByIdResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "anyOf": [ + { + "const": "text", + "type": "string" + }, + { + "const": "file", + "type": "string" + }, + { + "const": "presentation", + "type": "string" + }, + { + "const": "video", + "type": "string" + }, + { + "const": "quiz", + "type": "string" + } + ] + }, + "description": { + "type": "string" + }, + "fileType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "fileUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "quizDetails": { + "type": "object", + "properties": { + "questions": { + "type": "array", + "items": {} + }, + "questionCount": { + "type": "number" + }, + "correctAnswerCount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "wrongAnswerCount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "score": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "questions", + "questionCount", + "correctAnswerCount", + "wrongAnswerCount", + "score" + ] + }, + "displayOrder": { + "type": "number" + } + }, + "required": [ + "id", + "title", + "type", + "description", + "fileType", + "fileUrl", + "displayOrder" + ] + } + }, + "required": [ + "data" + ] + }, "BetaCreateLessonBody": { "type": "object", "allOf": [ { "type": "object", "properties": { - "updatedAt": { - "type": "string" - }, "title": { "type": "string" }, @@ -4329,6 +4480,9 @@ "title" ] } + }, + "updatedAt": { + "type": "string" } }, "required": [ @@ -4698,9 +4852,6 @@ { "type": "object", "properties": { - "updatedAt": { - "type": "string" - }, "title": { "type": "string" }, @@ -4810,6 +4961,9 @@ "title" ] } + }, + "updatedAt": { + "type": "string" } } }, @@ -4961,9 +5115,6 @@ "items": { "type": "object", "properties": { - "updatedAt": { - "type": "string" - }, "id": { "format": "uuid", "type": "string" @@ -5080,6 +5231,9 @@ "title" ] } + }, + "updatedAt": { + "type": "string" } }, "required": [ diff --git a/apps/web/app/api/generated-api.ts b/apps/web/app/api/generated-api.ts index 409f1d53..d1d2b570 100644 --- a/apps/web/app/api/generated-api.ts +++ b/apps/web/app/api/generated-api.ts @@ -609,6 +609,25 @@ export interface FileUploadResponse { fileUrl: string; } +export interface GetLessonByIdResponse { + data: { + /** @format uuid */ + id: string; + title: string; + type: string; + description: string; + fileType: string | null; + fileUrl: string | null; + quizDetails?: { + questions: any[]; + questionCount: number; + correctAnswerCount: number | null; + wrongAnswerCount: number | null; + score: number | null; + }; + }; +} + export type BetaCreateLessonBody = { updatedAt?: string; title: string; @@ -1960,6 +1979,20 @@ export class API extends HttpClient + this.request({ + path: `/api/lesson/${id}`, + method: "GET", + format: "json", + ...params, + }), + /** * No description *