diff --git a/apps/api/.eslintrc.js b/apps/api/.eslintrc.js index c87cec448..1504dfd48 100644 --- a/apps/api/.eslintrc.js +++ b/apps/api/.eslintrc.js @@ -5,7 +5,7 @@ module.exports = { tsconfigRootDir: __dirname, sourceType: "module", }, - plugins: ["@typescript-eslint", "import"], + plugins: ["@typescript-eslint", "import", "unused-imports"], extends: ["plugin:@typescript-eslint/recommended", "plugin:import/typescript"], root: true, env: { @@ -20,7 +20,7 @@ module.exports = { }, }, rules: { - "import/no-duplicates": "error", + "import/no-duplicates": ["error", { considerQueryString: true }], "import/order": [ "error", { @@ -49,5 +49,7 @@ module.exports = { "error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, ], + "@typescript-eslint/no-unused-vars": "off", + "unused-imports/no-unused-imports": "error", }, }; diff --git a/apps/api/package.json b/apps/api/package.json index cb651ed86..ebf7cdd85 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -97,6 +97,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-import": "^2.28.1", "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-unused-imports": "^4.1.4", "fishery": "^2.2.2", "jest": "^29.5.0", "prettier": "^3.0.0", diff --git a/apps/api/src/courses/courses.service.ts b/apps/api/src/courses/courses.service.ts index e86100f80..ca7e80560 100644 --- a/apps/api/src/courses/courses.service.ts +++ b/apps/api/src/courses/courses.service.ts @@ -295,6 +295,13 @@ export class CoursesService { authorId: courses.authorId, author: sql`${users.firstName} || ' ' || ${users.lastName}`, authorEmail: sql`${users.email}`, + hasFreeLessons: sql` + EXISTS ( + SELECT 1 + FROM ${courseLessons} + WHERE ${courseLessons.courseId} = ${courses.id} + AND ${courseLessons.isFree} = true + )`, }) .from(courses) .innerJoin(categories, eq(courses.categoryId, categories.id)) @@ -303,6 +310,7 @@ export class CoursesService { studentCourses, and(eq(courses.id, studentCourses.courseId), eq(studentCourses.studentId, userId)), ) + .leftJoin(courseLessons, eq(courses.id, courseLessons.courseId)) .where(and(eq(courses.id, id), eq(courses.archived, false))); if (!course) throw new NotFoundException("Course not found"); @@ -360,6 +368,7 @@ export class CoursesService { ELSE ${LessonProgress.notStarted} END) `, + isFree: courseLessons.isFree, }) .from(courseLessons) .innerJoin(lessons, eq(courseLessons.lessonId, lessons.id)) @@ -424,6 +433,7 @@ export class CoursesService { (SELECT COUNT(*) FROM ${lessonItems} WHERE ${lessonItems.lessonId} = ${lessons.id} AND ${lessonItems.lessonItemType} != 'text_block')::INTEGER`, + isFree: courseLessons.isFree, }) .from(courseLessons) .innerJoin(lessons, eq(courseLessons.lessonId, lessons.id)) @@ -777,6 +787,13 @@ export class CoursesService { )::INTEGER`, priceInCents: courses.priceInCents, currency: courses.currency, + hasFreeLessons: sql` + EXISTS ( + SELECT 1 + FROM ${courseLessons} + WHERE ${courseLessons.courseId} = ${courses.id} + AND ${courseLessons.isFree} = true + )`, }; } diff --git a/apps/api/src/courses/schemas/course.schema.ts b/apps/api/src/courses/schemas/course.schema.ts index 3d655684b..73261d749 100644 --- a/apps/api/src/courses/schemas/course.schema.ts +++ b/apps/api/src/courses/schemas/course.schema.ts @@ -20,6 +20,7 @@ export const courseSchema = Type.Object({ state: Type.Optional(Type.String()), archived: Type.Optional(Type.Boolean()), createdAt: Type.Optional(Type.String()), + hasFreeLessons: Type.Optional(Type.Boolean()), }); export const allCoursesSchema = Type.Array(courseSchema); diff --git a/apps/api/src/courses/schemas/showCourseCommon.schema.ts b/apps/api/src/courses/schemas/showCourseCommon.schema.ts index 15ffe30a5..51ab75442 100644 --- a/apps/api/src/courses/schemas/showCourseCommon.schema.ts +++ b/apps/api/src/courses/schemas/showCourseCommon.schema.ts @@ -22,6 +22,7 @@ export const commonShowCourseSchema = Type.Object({ priceInCents: Type.Number(), currency: Type.String(), archived: Type.Optional(Type.Boolean()), + hasFreeLessons: Type.Optional(Type.Boolean()), }); export type CommonShowCourse = Static; diff --git a/apps/api/src/e2e-data-seeds.ts b/apps/api/src/e2e-data-seeds.ts index d478aa984..1bce949a5 100644 --- a/apps/api/src/e2e-data-seeds.ts +++ b/apps/api/src/e2e-data-seeds.ts @@ -19,6 +19,7 @@ export const e2eCourses: NiceCourseData[] = [ imageUrl: "https://placehold.co/600x400", type: LESSON_TYPE.multimedia.key, state: STATUS.published.key, + isFree: false, items: [ { itemType: LESSON_ITEM_TYPE.text_block.key, diff --git a/apps/api/src/lessons/adminLessons.service.ts b/apps/api/src/lessons/adminLessons.service.ts index a73f5b448..0a0dadc91 100644 --- a/apps/api/src/lessons/adminLessons.service.ts +++ b/apps/api/src/lessons/adminLessons.service.ts @@ -19,6 +19,7 @@ import { import type { CreateLessonBody, UpdateLessonBody } from "./schemas/lesson.schema"; import type { LessonItemResponse } from "./schemas/lessonItem.schema"; +import type { UUIDType } from "src/common"; interface LessonsQuery { filters?: LessonsFilterSchema; @@ -136,8 +137,8 @@ export class AdminLessonsService { }; } - async getAvailableLessons() { - const availableLessons = await this.adminLessonsRepository.getAvailableLessons(); + async getAvailableLessons(courseId: UUIDType) { + const availableLessons = await this.adminLessonsRepository.getAvailableLessons(courseId); if (isEmpty(availableLessons)) throw new NotFoundException("Lessons not found"); @@ -169,6 +170,10 @@ export class AdminLessonsService { if (!lesson) throw new NotFoundException("Lesson not found"); } + async toggleLessonAsFree(courseId: UUIDType, lessonId: UUIDType, isFree: boolean) { + return await this.adminLessonsRepository.toggleLessonAsFree(courseId, lessonId, isFree); + } + async addLessonToCourse(courseId: string, lessonId: string, displayOrder?: number) { try { if (displayOrder === undefined) { diff --git a/apps/api/src/lessons/api/lessons.controller.ts b/apps/api/src/lessons/api/lessons.controller.ts index e22faa737..2b01f9aaa 100644 --- a/apps/api/src/lessons/api/lessons.controller.ts +++ b/apps/api/src/lessons/api/lessons.controller.ts @@ -31,31 +31,33 @@ import { LessonsService } from "../lessons.service"; import { type AllLessonsResponse, allLessonsSchema, - type CreateLessonBody, createLessonSchema, - type ShowLessonResponse, + lessonWithCountItems, showLessonSchema, - type UpdateLessonBody, updateLessonSchema, + type CreateLessonBody, + type ShowLessonResponse, + type UpdateLessonBody, + type LessonWithCountItems, } from "../schemas/lesson.schema"; import { - type FileInsertType, fileUpdateSchema, - type GetAllLessonItemsResponse, GetAllLessonItemsResponseSchema, - type GetSingleLessonItemsResponse, GetSingleLessonItemsResponseSchema, - type QuestionInsertType, questionUpdateSchema, - type TextBlockInsertType, textBlockUpdateSchema, + type FileInsertType, + type GetAllLessonItemsResponse, + type GetSingleLessonItemsResponse, + type QuestionInsertType, + type TextBlockInsertType, type UpdateFileBody, type UpdateQuestionBody, type UpdateTextBlockBody, } from "../schemas/lessonItem.schema"; import { - type LessonsFilterSchema, sortLessonFieldsOptions, + type LessonsFilterSchema, type SortLessonFieldsOptions, } from "../schemas/lessonQuery"; @@ -103,30 +105,13 @@ export class LessonsController { @Get("available-lessons") @Roles(USER_ROLES.tutor, USER_ROLES.admin) @Validate({ - response: baseResponse( - Type.Array( - Type.Object({ - id: Type.String(), - title: Type.String(), - description: Type.String(), - imageUrl: Type.String(), - itemsCount: Type.Number(), - }), - ), - ), + request: [{ type: "query", name: "courseId", schema: UUIDSchema, required: true }], + response: baseResponse(Type.Array(lessonWithCountItems)), }) - async getAvailableLessons(): Promise< - BaseResponse< - Array<{ - id: string; - title: string; - description: string; - imageUrl: string; - itemsCount: number; - }> - > - > { - const availableLessons = await this.adminLessonsService.getAvailableLessons(); + async getAvailableLessons( + @Query("courseId") courseId: UUIDType, + ): Promise>> { + const availableLessons = await this.adminLessonsService.getAvailableLessons(courseId); return new BaseResponse(availableLessons); } @@ -151,7 +136,7 @@ export class LessonsController { } @Get("lesson/:id") - @Roles(...Object.values(USER_ROLES)) + @Roles(USER_ROLES.tutor, USER_ROLES.admin) @Validate({ response: baseResponse(showLessonSchema), }) @@ -248,6 +233,37 @@ export class LessonsController { }); } + @Patch("course-lesson") + @Roles(USER_ROLES.tutor, 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({ diff --git a/apps/api/src/lessons/lessons.service.ts b/apps/api/src/lessons/lessons.service.ts index 6f63d375d..74750c123 100644 --- a/apps/api/src/lessons/lessons.service.ts +++ b/apps/api/src/lessons/lessons.service.ts @@ -38,12 +38,11 @@ export class LessonsService { id, userId, ); + const lesson = await this.lessonsRepository.getLessonForUser(courseId, id, userId); - if (!isAdmin && !accessCourseLessons) + if (!isAdmin && !accessCourseLessons && !lesson.isFree) throw new UnauthorizedException("You don't have access to this lesson"); - const lesson = await this.lessonsRepository.getLessonForUser(courseId, id, userId); - if (!lesson) throw new NotFoundException("Lesson not found"); const getImageUrl = async (url: string) => { @@ -82,24 +81,30 @@ export class LessonsService { const lessonProgress = await this.lessonsRepository.lessonProgress(courseId, lesson.id, userId); - if (!lessonProgress) throw new NotFoundException("Lesson progress not found"); + 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, - lessonProgress.quizCompleted, + isAdminOrFreeLessonWithoutLessonProgress ? false : lessonProgress.quizCompleted, ); return { ...lesson, imageUrl, lessonItems: questionLessonItems, - itemsCount: lessonProgress.lessonItemCount, - itemsCompletedCount: lessonProgress.completedLessonItemCount, - quizScore: lessonProgress.quizScore, - lessonProgress: - lessonProgress.completedLessonItemCount === 0 + itemsCount: isAdminOrFreeLessonWithoutLessonProgress ? 0 : lessonProgress.lessonItemCount, + itemsCompletedCount: isAdminOrFreeLessonWithoutLessonProgress + ? 0 + : lessonProgress.completedLessonItemCount, + quizScore: isAdminOrFreeLessonWithoutLessonProgress ? 0 : lessonProgress.quizScore, + lessonProgress: isAdminOrFreeLessonWithoutLessonProgress + ? LessonProgress.notStarted + : lessonProgress.completedLessonItemCount === 0 ? LessonProgress.notStarted : lessonProgress.completedLessonItemCount > 0 ? LessonProgress.inProgress @@ -114,12 +119,12 @@ export class LessonsService { userId, ); - if (!accessCourseLessons) + if (!accessCourseLessons.isAssigned && !accessCourseLessons.isFree) throw new UnauthorizedException("You don't have assignment to this lesson"); const quizProgress = await this.lessonsRepository.getQuizProgress(courseId, lessonId, userId); - if (quizProgress.quizCompleted) throw new ConflictException("Quiz already completed"); + if (quizProgress?.quizCompleted) throw new ConflictException("Quiz already completed"); const lessonItemsCount = await this.lessonsRepository.getLessonItemCount(lessonId); diff --git a/apps/api/src/lessons/repositories/adminLessons.repository.ts b/apps/api/src/lessons/repositories/adminLessons.repository.ts index ff6d474ca..f9c29b681 100644 --- a/apps/api/src/lessons/repositories/adminLessons.repository.ts +++ b/apps/api/src/lessons/repositories/adminLessons.repository.ts @@ -54,7 +54,7 @@ export class AdminLessonsRepository { return lesson; } - async getAvailableLessons() { + async getAvailableLessons(courseId: UUIDType) { return await this.db .select({ id: lessons.id, @@ -65,8 +65,13 @@ export class AdminLessonsRepository { (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), @@ -167,6 +172,14 @@ export class AdminLessonsRepository { }); } + 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) diff --git a/apps/api/src/lessons/repositories/lessons.repository.ts b/apps/api/src/lessons/repositories/lessons.repository.ts index d744f1bc6..0bf84ff19 100644 --- a/apps/api/src/lessons/repositories/lessons.repository.ts +++ b/apps/api/src/lessons/repositories/lessons.repository.ts @@ -41,8 +41,14 @@ export class LessonsRepository { ELSE FALSE END `, + isFree: courseLessons.isFree, + enrolled: sql`CASE WHEN ${studentCourses.id} IS NOT NULL THEN true ELSE false END`, }) .from(lessons) + .innerJoin( + courseLessons, + and(eq(courseLessons.lessonId, lessons.id), eq(courseLessons.courseId, courseId)), + ) .leftJoin( studentLessonsProgress, and( @@ -51,6 +57,10 @@ export class LessonsRepository { eq(studentLessonsProgress.studentId, userId), ), ) + .leftJoin( + studentCourses, + and(eq(studentCourses.courseId, courseId), eq(studentCourses.studentId, userId)), + ) .where( and( eq(lessons.id, lessonId), @@ -207,17 +217,20 @@ export class LessonsRepository { return this.db .select({ id: lessons.id, - studentCourseId: studentCourses.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, eq(courseLessons.lessonId, lessons.id)) + .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(studentCourses.courseId, courseId), eq(lessons.archived, false), eq(lessons.id, lessonId), eq(lessons.state, STATES.published), @@ -524,4 +537,20 @@ export class LessonsRepository { ), ); } + + async createLessonProgress( + courseId: UUIDType, + lessonId: UUIDType, + userId: UUIDType, + lessonItemCount: number, + ) { + return await this.db.insert(studentLessonsProgress).values({ + studentId: userId, + lessonId, + courseId, + quizCompleted: false, + lessonItemCount, + completedLessonItemCount: 0, + }); + } } diff --git a/apps/api/src/lessons/schemas/lesson.schema.ts b/apps/api/src/lessons/schemas/lesson.schema.ts index 2db460656..cc066b4ab 100644 --- a/apps/api/src/lessons/schemas/lesson.schema.ts +++ b/apps/api/src/lessons/schemas/lesson.schema.ts @@ -19,6 +19,8 @@ export const lessonSchema = Type.Object({ Type.Literal(LessonProgress.notStarted), ]), ), + isFree: Type.Optional(Type.Boolean()), + enrolled: Type.Optional(Type.Boolean()), state: Type.Optional(Type.String()), archived: Type.Optional(Type.Boolean()), isSubmitted: Type.Optional(Type.Boolean()), @@ -41,8 +43,16 @@ export const lesson = Type.Object({ imageUrl: Type.String(), description: Type.String(), type: Type.String(), + isFree: Type.Boolean(), }); +export const lessonWithCountItems = Type.Intersect([ + Type.Omit(lesson, ["type"]), + Type.Object({ + itemsCount: Type.Number(), + }), +]); + export const allLessonsSchema = Type.Array(lessonSchema); export const showLessonSchema = Type.Object({ @@ -60,6 +70,7 @@ export const showLessonSchema = Type.Object({ }); export type Lesson = Static; +export type LessonWithCountItems = Static; export type LessonResponse = Static; export type ShowLessonResponse = Static; export type AllLessonsResponse = Static; diff --git a/apps/api/src/nice-data-seeds.ts b/apps/api/src/nice-data-seeds.ts index 527a295a4..805caf922 100644 --- a/apps/api/src/nice-data-seeds.ts +++ b/apps/api/src/nice-data-seeds.ts @@ -24,6 +24,7 @@ export const niceCourses: NiceCourseData[] = [ "In this lesson, you will learn how to use HTML to create the basic structure of a website. We'll cover common HTML elements such as headings, paragraphs, links, and images, helping you understand how to build a solid foundation for your web pages.", state: STATUS.published.key, imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, items: [ { itemType: LESSON_ITEM_TYPE.text_block.key, @@ -64,6 +65,16 @@ export const niceCourses: NiceCourseData[] = [ isCorrect: true, position: 1, }, + { + optionText: "grid", + isCorrect: false, + position: 2, + }, + { + optionText: "flex", + isCorrect: false, + position: 3, + }, ], }, { @@ -246,6 +257,150 @@ export const niceCourses: NiceCourseData[] = [ "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.", state: STATUS.published.key, imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.single_choice.key, + questionBody: "Which of the following HTML tags is used to create an image?", + state: STATUS.published.key, + questionAnswers: [ + { + optionText: "", + isCorrect: true, + position: 0, + }, + { + optionText: "", + isCorrect: false, + position: 1, + }, + { + optionText: "