diff --git a/apps/api/src/chapter/adminChapter.service.ts b/apps/api/src/chapter/adminChapter.service.ts index 3101d474..ed6a0cae 100644 --- a/apps/api/src/chapter/adminChapter.service.ts +++ b/apps/api/src/chapter/adminChapter.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable, NotFoundException } from "@nestjs/common"; -import { eq, gte, lte, sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { DatabasePg } from "src/common"; import { FileService } from "src/file/file.service"; @@ -230,27 +230,12 @@ export class AdminChapterService { const newDisplayOrder = chapterObject.displayOrder; - await this.db.transaction(async (trx) => { - await trx - .update(chapters) - .set({ - displayOrder: sql`CASE - WHEN ${eq(chapters.id, chapterToUpdate.id)} - THEN ${newDisplayOrder} - WHEN ${newDisplayOrder < oldDisplayOrder} - AND ${gte(chapters.displayOrder, newDisplayOrder)} - AND ${lte(chapters.displayOrder, oldDisplayOrder)} - THEN ${chapters.displayOrder} + 1 - WHEN ${newDisplayOrder > oldDisplayOrder} - AND ${lte(chapters.displayOrder, newDisplayOrder)} - AND ${gte(chapters.displayOrder, oldDisplayOrder)} - THEN ${chapters.displayOrder} - 1 - ELSE ${chapters.displayOrder} - END - `, - }) - .where(eq(chapters.courseId, chapterToUpdate.courseId)); - }); + await this.adminChapterRepository.changeChapterDisplayOrder( + chapterToUpdate.courseId, + chapterToUpdate.id, + oldDisplayOrder, + newDisplayOrder, + ); } // async updateLesson(id: string, body: UpdateLessonBody) { // const [lesson] = await this.adminChapterRepository.updateLesson(id, body); diff --git a/apps/api/src/chapter/chapter.controller.ts b/apps/api/src/chapter/chapter.controller.ts index 1bfdaa6e..4aabb60c 100644 --- a/apps/api/src/chapter/chapter.controller.ts +++ b/apps/api/src/chapter/chapter.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Patch, Post, Query, UseGuards } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Patch, Post, Query, UseGuards } from "@nestjs/common"; import { Type } from "@sinclair/typebox"; import { Validate } from "nestjs-typebox"; @@ -6,11 +6,17 @@ import { baseResponse, BaseResponse, UUIDSchema, type UUIDType } from "src/commo import { Roles } from "src/common/decorators/roles.decorator"; 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 { USER_ROLES, type UserRole } from "src/user/schemas/userRoles"; import { AdminChapterService } from "./adminChapter.service"; import { ChapterService } from "./chapter.service"; -import { CreateChapterBody, createChapterSchema } from "./schemas/chapter.schema"; +import { + CreateChapterBody, + createChapterSchema, + showChapterSchema, +} from "./schemas/chapter.schema"; + +import type { ShowChapterResponse } from "./schemas/chapter.schema"; @Controller("chapter") @UseGuards(RolesGuard) @@ -20,25 +26,21 @@ export class ChapterController { private readonly adminChapterService: AdminChapterService, ) {} - // @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() + @Roles(...Object.values(USER_ROLES)) + @Validate({ + request: [{ type: "query", name: "id", schema: UUIDSchema, required: true }], + response: baseResponse(showChapterSchema), + }) + async getChapterWithLesson( + @Query("id") id: UUIDType, + @CurrentUser("role") userRole: UserRole, + @CurrentUser("userId") userId: UUIDType, + ): Promise> { + return new BaseResponse( + await this.chapterService.getChapterWithLessons(id, userId, userRole === USER_ROLES.ADMIN), + ); + } // @Get("lesson/:id") // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) @@ -141,7 +143,7 @@ export class ChapterController { // } // @Delete(":courseId/:lessonId") - // @Roles(USER_ROLES.TEACHER, USER_ROLES.ADMIN) + // @Roles(USER_ROLES.teacher, USER_ROLES.admin) // @Validate({ // request: [ // { type: "param", name: "courseId", schema: UUIDSchema }, diff --git a/apps/api/src/chapter/chapter.service.ts b/apps/api/src/chapter/chapter.service.ts index a4e9ade0..ddee3e15 100644 --- a/apps/api/src/chapter/chapter.service.ts +++ b/apps/api/src/chapter/chapter.service.ts @@ -1,86 +1,38 @@ -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common"; import { EventBus } from "@nestjs/cqrs"; import { DatabasePg } from "src/common"; +import { LessonRepository } from "src/lesson/lesson.repository"; import { ChapterRepository } from "./repositories/chapter.repository"; +import type { ShowChapterResponse } from "src/chapter/schemas/chapter.schema"; +import type { UUIDType } from "src/common"; + @Injectable() export class ChapterService { constructor( @Inject("DB") private readonly db: DatabasePg, private readonly chapterRepository: ChapterRepository, + private readonly lessonRepository: LessonRepository, private readonly eventBus: EventBus, ) {} - // async getChapterWithDetails(id: UUIDType, userId: UUIDType, isStudent: boolean) { - // const hasCourseAccess = await this.chapterRepository.checkChapterAssignment(id, userId); - // if (!hasCourseAccess && isStudent) throw new Error("You don't have access to this chapter"); - - // const [chapter] = await this.chapterRepository.getChapterWithDetails(id, userId); - // if (!chapter) throw new Error("Chapter not found"); - - // return chapter; - // } - - // async getChapter( - // id: UUIDType, - // userId: UUIDType, - // isAdmin?: boolean, - // ): Promise { - // const [courseAccess] = await this.chapterRepository.checkChapterAssignment(id, userId); - // const chapter = await this.chapterRepository.getChapterForUser(id, userId); - - // if (!isAdmin && !courseAccess && !chapter.isFreemium) - // throw new UnauthorizedException("You don't have access to this lesson"); - - // if (!chapter) throw new NotFoundException("Lesson not found"); - - // // const chapterProgress = await this.chapterRepository.getChapterProgressForStudent( - // // chapter.id, - // // userId, - // // ); - - // // if (lesson.type !== LESSON_TYPE.quiz.key) { - // // const lessonItems = await this.getLessonItems(chapter.id, 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, - // // ); + async getChapterWithLessons( + id: UUIDType, + userId: UUIDType, + isAdmin?: boolean, + ): Promise { + const [courseAccess] = await this.chapterRepository.checkChapterAssignment(id, userId); + const chapter = await this.chapterRepository.getChapterForUser(id, userId); - // // if (!lessonProgress && !isAdmin && !lesson.isFree) - // // throw new NotFoundException("Lesson progress not found"); + if (!isAdmin && !courseAccess && !chapter.isFreemium) + throw new UnauthorizedException("You don't have access to this lesson"); - // // const isAdminOrFreeLessonWithoutLessonProgress = (isAdmin || lesson.isFree) && !lessonProgress; + if (!chapter) throw new NotFoundException("Chapter not found"); - // // const questionLessonItems = await this.getLessonQuestions( - // // lesson, - // // courseId, - // // userId, - // // isAdminOrFreeLessonWithoutLessonProgress ? false : lessonProgress.quizCompleted, - // // ); + const chapterLessonList = await this.lessonRepository.getLessonsByChapterId(id); - // return chapter; - // } + return { ...chapter, lessons: chapterLessonList }; + } } diff --git a/apps/api/src/chapter/repositories/adminChapter.repository.ts b/apps/api/src/chapter/repositories/adminChapter.repository.ts index d1d0a6cd..1650aa2f 100644 --- a/apps/api/src/chapter/repositories/adminChapter.repository.ts +++ b/apps/api/src/chapter/repositories/adminChapter.repository.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from "@nestjs/common"; -import { eq, and, sql } from "drizzle-orm"; +import { and, eq, gte, lte, sql } from "drizzle-orm"; import { DatabasePg, type UUIDType } from "src/common"; import { chapters, lessons, questionAnswerOptions, questions } from "src/storage/schema"; @@ -16,6 +16,35 @@ export class AdminChapterRepository { return await this.db.select().from(chapters).where(eq(chapters.id, chapterId)); } + async changeChapterDisplayOrder( + courseId: UUIDType, + chapterId: UUIDType, + oldDisplayOrder: number, + newDisplayOrder: number, + ) { + await this.db.transaction(async (trx) => { + await trx + .update(chapters) + .set({ + displayOrder: sql`CASE + WHEN ${eq(chapters.id, chapterId)} + THEN ${newDisplayOrder} + WHEN ${newDisplayOrder < oldDisplayOrder} + AND ${gte(chapters.displayOrder, newDisplayOrder)} + AND ${lte(chapters.displayOrder, oldDisplayOrder)} + THEN ${chapters.displayOrder} + 1 + WHEN ${newDisplayOrder > oldDisplayOrder} + AND ${lte(chapters.displayOrder, newDisplayOrder)} + AND ${gte(chapters.displayOrder, oldDisplayOrder)} + THEN ${chapters.displayOrder} - 1 + ELSE ${chapters.displayOrder} + END + `, + }) + .where(eq(chapters.courseId, courseId)); + }); + } + // async getLessons(conditions: any[], sortOrder: any) { // return await this.db // .select({ diff --git a/apps/api/src/chapter/repositories/chapter.repository.ts b/apps/api/src/chapter/repositories/chapter.repository.ts index cf249bf0..3afb3242 100644 --- a/apps/api/src/chapter/repositories/chapter.repository.ts +++ b/apps/api/src/chapter/repositories/chapter.repository.ts @@ -3,13 +3,16 @@ import { and, eq, isNotNull, sql } from "drizzle-orm"; import { DatabasePg, type UUIDType } from "src/common"; import { chapters, lessons, studentChapterProgress, studentCourses } from "src/storage/schema"; +import { PROGRESS_STATUSES } from "src/utils/types/progress.type"; + +import type { ProgressStatus } from "src/utils/types/progress.type"; @Injectable() export class ChapterRepository { constructor(@Inject("DB") private readonly db: DatabasePg) {} async getChapterWithDetails(id: UUIDType, userId: UUIDType, isStudent: boolean) { - return await this.db + return this.db .select({ id: chapters.id, title: chapters.title, @@ -58,16 +61,22 @@ export class ChapterRepository { .where(and(eq(chapters.isPublished, true), eq(chapters.id, id))); } - // TODO: check this functions \/ async getChapterForUser(id: UUIDType, userId: UUIDType) { - const [lesson] = await this.db + const [chapter] = await this.db .select({ + displayOrder: sql`${lessons.displayOrder}`, id: chapters.id, title: chapters.title, isFreemium: chapters.isFreemium, enrolled: sql`CASE WHEN ${studentCourses.id} IS NOT NULL THEN true ELSE false END`, lessonCount: chapters.lessonCount, completedLessonCount: sql`COALESCE(${studentChapterProgress.completedLessonCount}, 0)`, + progress: sql` + CASE ${studentChapterProgress.completedAt} IS NOT NULL + THEN ${PROGRESS_STATUSES.COMPLETED} + WHEN ${studentChapterProgress.completedAt} IS NULL AND ${studentChapterProgress.completedLessonCount} < 0 + THEN ${PROGRESS_STATUSES.IN_PROGRESS} + ELSE ${PROGRESS_STATUSES.NOT_STARTED}`, }) .from(chapters) .leftJoin( @@ -84,7 +93,7 @@ export class ChapterRepository { ) .where(and(eq(chapters.id, id), eq(chapters.isPublished, true))); - return lesson; + return chapter; } async getChapter(id: UUIDType) { @@ -100,18 +109,4 @@ export class ChapterRepository { return chapter; } - - async getChapterProgressForStudent(chapterId: UUIDType, userId: UUIDType) { - const [chapterProgress] = await this.db - .select({}) - .from(studentChapterProgress) - .where( - and( - eq(studentChapterProgress.studentId, userId), - eq(studentChapterProgress.chapterId, chapterId), - ), - ); - - return chapterProgress; - } } diff --git a/apps/api/src/chapter/schemas/chapter.schema.ts b/apps/api/src/chapter/schemas/chapter.schema.ts index 36366c58..9173d08a 100644 --- a/apps/api/src/chapter/schemas/chapter.schema.ts +++ b/apps/api/src/chapter/schemas/chapter.schema.ts @@ -1,9 +1,8 @@ import { type Static, Type } from "@sinclair/typebox"; import { UUIDSchema } from "src/common"; -import { lessonItemSelectSchema } from "src/lesson/lesson.schema"; - -import { ChapterProgress } from "./chapter.types"; +import { lessonSchema } from "src/lesson/lesson.schema"; +import { PROGRESS_STATUSES } from "src/utils/types/progress.type"; export const chapterSchema = Type.Object({ id: UUIDSchema, @@ -12,9 +11,9 @@ export const chapterSchema = Type.Object({ completedLessonCount: Type.Optional(Type.Number()), chapterProgress: Type.Optional( Type.Union([ - Type.Literal(ChapterProgress.completed), - Type.Literal(ChapterProgress.inProgress), - Type.Literal(ChapterProgress.notStarted), + Type.Literal(PROGRESS_STATUSES.COMPLETED), + Type.Literal(PROGRESS_STATUSES.IN_PROGRESS), + Type.Literal(PROGRESS_STATUSES.NOT_STARTED), ]), ), isFreemium: Type.Optional(Type.Boolean()), @@ -22,11 +21,12 @@ export const chapterSchema = Type.Object({ isPublished: Type.Optional(Type.Boolean()), isSubmitted: Type.Optional(Type.Boolean()), createdAt: Type.Optional(Type.String()), - quizScore: Type.Optional(Type.Number()), + quizCount: Type.Optional(Type.Number()), + displayOrder: Type.Number(), }); export const createChapterSchema = Type.Intersect([ - Type.Omit(chapterSchema, ["id", "lessonCount", "completedLessonCount"]), + Type.Omit(chapterSchema, ["id", "lessonCount", "completedLessonCount", "displayOrder"]), Type.Object({ courseId: UUIDSchema, }), @@ -34,14 +34,10 @@ export const createChapterSchema = Type.Intersect([ export const updateChapterSchema = Type.Partial(createChapterSchema); -// TODO: update it otr remove if not needed export const chapter = Type.Object({ id: UUIDSchema, title: Type.String(), - imageUrl: Type.String(), - description: Type.String(), - type: Type.String(), - isFree: Type.Boolean(), + isFreemium: Type.Boolean(), }); export const chapterWithLessonCount = Type.Intersect([ @@ -55,14 +51,8 @@ export const allChapterSchema = Type.Array(chapterSchema); export const showChapterSchema = Type.Object({ ...chapterSchema.properties, - lessonItems: Type.Array(lessonItemSelectSchema), - chapterProgress: Type.Optional( - Type.Union([ - Type.Literal(ChapterProgress.completed), - Type.Literal(ChapterProgress.inProgress), - Type.Literal(ChapterProgress.notStarted), - ]), - ), + quizCount: Type.Optional(Type.Number()), + lessons: Type.Array(lessonSchema), }); export type Chapter = Static; diff --git a/apps/api/src/chapter/schemas/chapter.types.ts b/apps/api/src/chapter/schemas/chapter.types.ts deleted file mode 100644 index f00362a3..00000000 --- a/apps/api/src/chapter/schemas/chapter.types.ts +++ /dev/null @@ -1,18 +0,0 @@ -// import type { LESSON_ITEM_TYPE } from "../chapter.type"; - -// export const LessonTypes = { -// presentation: "Presentation", -// external_presentation: "External Presentation", -// video: "Video", -// external_video: "External Video", -// } as const; - -export const ChapterProgress = { - notStarted: "not_started", - inProgress: "in_progress", - completed: "completed", -} as const; - -// export type LessonItemTypes = keyof typeof LESSON_ITEM_TYPE; - -export type ChapterProgressType = (typeof ChapterProgress)[keyof typeof ChapterProgress]; diff --git a/apps/api/src/courses/course.controller.ts b/apps/api/src/courses/course.controller.ts index 043bd4a2..492a5d56 100644 --- a/apps/api/src/courses/course.controller.ts +++ b/apps/api/src/courses/course.controller.ts @@ -129,6 +129,7 @@ export class CourseController { @Query("page") page: number, @Query("perPage") perPage: number, @Query("sort") sort: SortCourseFieldsOptions, + @Query("excludeCourseId") excludeCourseId: UUIDType, @CurrentUser("userId") currentUserId: UUIDType, ): Promise> { const filters: CoursesFilterSchema = { @@ -140,7 +141,7 @@ export class CourseController { ? [creationDateRangeStart, creationDateRangeEnd] : undefined, }; - const query = { filters, page, perPage, sort }; + const query = { filters, page, perPage, sort, excludeCourseId }; const data = await this.courseService.getAvailableCourses(query, currentUserId); @@ -149,14 +150,30 @@ export class CourseController { @Get("teacher-courses") @Validate({ - request: [{ type: "query", name: "authorId", schema: UUIDSchema, required: true }], + request: [ + { type: "query", name: "authorId", schema: UUIDSchema, required: true }, + { + type: "query", + name: "scope", + schema: Type.Union([ + Type.Literal("all"), + Type.Literal("enrolled"), + Type.Literal("available"), + ]), + }, + { type: "query", name: "excludeCourseId", schema: UUIDSchema }, + ], response: baseResponse(allCoursesSchema), }) async getTeacherCourses( @Query("authorId") authorId: UUIDType, + @Query("scope") scope: "all" | "enrolled" | "available" = "all", + @Query("excludeCourseId") excludeCourseId: UUIDType, @CurrentUser("userId") currentUserId: UUIDType, ): Promise> { - return new BaseResponse(await this.courseService.getTeacherCourses(authorId)); + const query = { authorId, currentUserId, excludeCourseId, scope }; + + return new BaseResponse(await this.courseService.getTeacherCourses(query)); } @Get("course") diff --git a/apps/api/src/courses/course.service.ts b/apps/api/src/courses/course.service.ts index 71271068..a68b9972 100644 --- a/apps/api/src/courses/course.service.ts +++ b/apps/api/src/courses/course.service.ts @@ -15,20 +15,20 @@ import { ilike, inArray, isNotNull, - isNull, like, + ne, sql, } from "drizzle-orm"; -import { LESSON_ITEM_TYPE, LESSON_TYPE } from "src/chapter/chapter.type"; +import { LESSON_TYPE } from "src/chapter/chapter.type"; import { AdminChapterRepository } from "src/chapter/repositories/adminChapter.repository"; -import { ChapterProgress } from "src/chapter/schemas/chapter.types"; import { DatabasePg } from "src/common"; import { addPagination, DEFAULT_PAGE_SIZE } from "src/common/pagination"; import { FileService } from "src/file/file.service"; import { LESSON_TYPES } from "src/lesson/lesson.type"; import { StatisticsRepository } from "src/statistics/repositories/statistics.repository"; import { USER_ROLES } from "src/user/schemas/userRoles"; +import { PROGRESS_STATUSES } from "src/utils/types/progress.type"; import { getSortOptions } from "../common/helpers/getSortOptions"; import { @@ -56,9 +56,11 @@ import type { AllCoursesForTeacherResponse, AllCoursesResponse } from "./schemas import type { CreateCourseBody } from "./schemas/createCourse.schema"; import type { CommonShowCourse } from "./schemas/showCourseCommon.schema"; import type { UpdateCourseBody } from "./schemas/updateCourse.schema"; -import type { ChapterProgressType } from "src/chapter/schemas/chapter.types"; +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 * as schema from "src/storage/schema"; +import type { ProgressStatus } from "src/utils/types/progress.type"; @Injectable() export class CourseService { @@ -228,20 +230,18 @@ export class CourseService { const { sortOrder, sortedField } = getSortOptions(sort); return this.db.transaction(async (trx) => { - const notEnrolledCourses: Record[] = await trx.execute(sql` - SELECT ${courses.id} AS "courseId" - FROM ${courses} - WHERE ${courses.id} NOT IN ( - SELECT DISTINCT ${studentCourses.courseId} - FROM ${studentCourses} - WHERE ${studentCourses.studentId} = ${currentUserId} - )`); - const notEnrolledCourseIds = notEnrolledCourses.map(({ courseId }) => courseId); + const availableCourseIds = await this.getAvailableCourseIds( + currentUserId, + trx, + undefined, + query.excludeCourseId, + ); const conditions = [eq(courses.isPublished, true)]; conditions.push(...this.getFiltersConditions(filters)); - if (notEnrolledCourses.length > 0) { - conditions.push(inArray(courses.id, notEnrolledCourseIds)); + + if (availableCourseIds.length > 0) { + conditions.push(inArray(courses.id, availableCourseIds)); } const queryDB = trx @@ -331,10 +331,6 @@ export class CourseService { priceInCents: courses.priceInCents, currency: courses.currency, authorId: courses.authorId, - // is no needed on frontend * - author: sql`${users.firstName} || ' ' || ${users.lastName}`, - // is no needed on frontend * - authorEmail: sql`${users.email}`, hasFreeChapter: sql` EXISTS ( SELECT 1 @@ -375,34 +371,16 @@ export class CourseService { WHERE ${lessons.chapterId} = ${chapters.id} AND ${lessons.type} = ${LESSON_TYPE.quiz.key})::INTEGER`, completedLessonCount: sql`COALESCE(${studentChapterProgress.completedLessonCount}, 0)`, - // TODO: add lessonProgressState to student lessons progress table - chapterProgress: sql` - (CASE - WHEN ( - SELECT COUNT(*) - FROM ${lessons} - WHERE ${lessons.chapterId} = ${chapters.id} - AND ${lessons.type} != ${LESSON_ITEM_TYPE.text_block.key} - ) = ( - SELECT COUNT(*) - FROM ${studentLessonProgress} - LEFT JOIN ${lessons} ON ${lessons.chapterId} = ${chapters.id} - WHERE ${studentLessonProgress.lessonId} = ${lessons.id} - AND ${studentLessonProgress.studentId} = ${userId} - ) - THEN ${ChapterProgress.completed} - WHEN ( - SELECT COUNT(*) - FROM ${studentLessonProgress} - LEFT JOIN ${lessons} ON ${lessons.id} = ${studentLessonProgress.lessonId} - WHERE ${studentLessonProgress.lessonId} = ${lessons.id} - AND ${studentLessonProgress.studentId} = ${userId} - ) > 0 - THEN ${ChapterProgress.inProgress} - ELSE ${ChapterProgress.notStarted} - END) + chapterProgress: sql` + CASE + WHEN ${studentChapterProgress.completedAt} IS NOT NULL THEN ${PROGRESS_STATUSES.COMPLETED} + WHEN ${studentChapterProgress.completedAt} IS NULL + AND ${studentChapterProgress.completedLessonCount} > 0 THEN ${PROGRESS_STATUSES.IN_PROGRESS} + ELSE ${PROGRESS_STATUSES.NOT_STARTED} + END `, - isFree: chapters.isFreemium, + isFreemium: chapters.isFreemium, + displayOrder: sql`${chapters.displayOrder}`, lessons: sql` COALESCE( ( @@ -488,7 +466,7 @@ export class CourseService { .select({ id: chapters.id, title: chapters.title, - displayOrder: chapters.displayOrder, + displayOrder: sql`${chapters.displayOrder}`, lessonCount: chapters.lessonCount, isFree: chapters.isFreemium, lessons: sql` @@ -556,6 +534,7 @@ export class CourseService { const courseChapterList = await this.db .select({ + displayOrder: sql`${lessons.displayOrder}`, id: chapters.id, title: chapters.title, lessonCount: chapters.lessonCount, @@ -586,8 +565,36 @@ export class CourseService { }; } - //TODO: Needs to be refactored - async getTeacherCourses(authorId: UUIDType): Promise { + async getTeacherCourses({ + currentUserId, + authorId, + scope, + excludeCourseId, + }: { + currentUserId: UUIDType; + authorId: UUIDType; + scope: "all" | "enrolled" | "available"; + excludeCourseId?: UUIDType; + }): Promise { + const conditions = [eq(courses.isPublished, true), eq(courses.authorId, authorId)]; + + if (scope === "enrolled") { + conditions.push(eq(studentCourses.studentId, currentUserId)); + } + + if (scope === "available") { + const availableCourseIds = await this.getAvailableCourseIds( + currentUserId, + this.db, + authorId, + excludeCourseId, + ); + + if (availableCourseIds.length) { + conditions.push(inArray(courses.id, availableCourseIds)); + } + } + return this.db .select({ id: courses.id, @@ -616,13 +623,7 @@ export class CourseService { .leftJoin(studentCourses, eq(studentCourses.courseId, courses.id)) .leftJoin(categories, eq(courses.categoryId, categories.id)) .leftJoin(users, eq(courses.authorId, users.id)) - .where( - and( - eq(courses.isPublished, true), - isNull(studentCourses.studentId), - eq(courses.authorId, authorId), - ), - ) + .where(and(...conditions)) .groupBy( courses.id, courses.title, @@ -1045,4 +1046,33 @@ export class CourseService { return courses.title; } } + + private async getAvailableCourseIds( + currentUserId: UUIDType, + trx: PostgresJsDatabase, + authorId?: UUIDType, + excludeCourseId?: UUIDType, + ) { + const conditions = []; + + if (authorId) { + conditions.push(eq(courses.authorId, authorId)); + } + + if (excludeCourseId) { + conditions.push(ne(courses.id, excludeCourseId)); + } + + const availableCourses: Record[] = await trx.execute(sql` + SELECT ${courses.id} AS "courseId" + FROM ${courses} + WHERE ${conditions.length ? and(...conditions) : true} AND ${courses.id} NOT IN ( + SELECT DISTINCT ${studentCourses.courseId} + FROM ${studentCourses} + WHERE ${studentCourses.studentId} = ${currentUserId} + ) + `); + + return availableCourses.map(({ courseId }) => courseId); + } } diff --git a/apps/api/src/courses/schemas/courseQuery.ts b/apps/api/src/courses/schemas/courseQuery.ts index 7e2b3125..b7dbe06d 100644 --- a/apps/api/src/courses/schemas/courseQuery.ts +++ b/apps/api/src/courses/schemas/courseQuery.ts @@ -68,4 +68,5 @@ export type CoursesQuery = { sort?: SortCourseFieldsOptions; currentUserId?: UUIDType; currentUserRole?: UserRole; + excludeCourseId?: UUIDType; }; diff --git a/apps/api/src/courses/schemas/showCourseCommon.schema.ts b/apps/api/src/courses/schemas/showCourseCommon.schema.ts index f4dbb5cb..3c0ca82c 100644 --- a/apps/api/src/courses/schemas/showCourseCommon.schema.ts +++ b/apps/api/src/courses/schemas/showCourseCommon.schema.ts @@ -4,25 +4,23 @@ import { chapterSchema } from "src/chapter/schemas/chapter.schema"; import { UUIDSchema } from "src/common"; export const commonShowCourseSchema = Type.Object({ - id: Type.String({ format: "uuid" }), - title: Type.String(), - thumbnailUrl: Type.Optional(Type.String()), - description: Type.String(), + archived: Type.Optional(Type.Boolean()), + authorId: Type.Optional(UUIDSchema), category: Type.String(), categoryId: Type.Optional(Type.String({ format: "uuid" })), - authorId: Type.Optional(UUIDSchema), - author: Type.Optional(Type.String()), - authorEmail: Type.Optional(Type.String()), - courseChapterCount: Type.Number(), + chapters: Type.Array(chapterSchema), completedChapterCount: Type.Optional(Type.Number()), + courseChapterCount: Type.Number(), + currency: Type.String(), + description: Type.String(), enrolled: Type.Optional(Type.Boolean()), + hasFreeChapter: Type.Optional(Type.Boolean()), + id: Type.String({ format: "uuid" }), isPublished: Type.Union([Type.Boolean(), Type.Null()]), isScorm: Type.Optional(Type.Boolean()), - chapters: Type.Array(chapterSchema), priceInCents: Type.Number(), - currency: Type.String(), - archived: Type.Optional(Type.Boolean()), - hasFreeChapter: Type.Optional(Type.Boolean()), + thumbnailUrl: Type.Optional(Type.String()), + title: Type.String(), }); export type CommonShowCourse = Static; diff --git a/apps/api/src/courses/validations/validations.ts b/apps/api/src/courses/validations/validations.ts index 4c713e66..084d8801 100644 --- a/apps/api/src/courses/validations/validations.ts +++ b/apps/api/src/courses/validations/validations.ts @@ -1,6 +1,6 @@ import { Type } from "@sinclair/typebox"; -import { paginatedResponse } from "src/common"; +import { paginatedResponse, UUIDSchema } from "src/common"; import { allCoursesSchema } from "src/courses/schemas/course.schema"; import { sortCourseFieldsOptions } from "src/courses/schemas/courseQuery"; @@ -63,5 +63,6 @@ export const coursesValidation = { }, { type: "query" as const, name: "perPage", schema: Type.Number() }, { type: "query" as const, name: "sort", schema: sortCourseFieldsOptions }, + { type: "query" as const, name: "excludeCourseId", schema: UUIDSchema }, ], }; diff --git a/apps/api/src/lesson/adminLesson.repository.ts b/apps/api/src/lesson/adminLesson.repository.ts index d9679c38..65e26a56 100644 --- a/apps/api/src/lesson/adminLesson.repository.ts +++ b/apps/api/src/lesson/adminLesson.repository.ts @@ -14,7 +14,7 @@ import type { CreateQuizLessonBody, UpdateLessonBody, UpdateQuizLessonBody, -} from "./lesson.schem"; +} from "./lesson.schema"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type * as schema from "src/storage/schema"; @@ -41,7 +41,7 @@ export class AdminLessonRepository { } async updateQuizLessonWithQuestionsAndOptions(id: UUIDType, data: UpdateQuizLessonBody) { - return await this.db + return this.db .update(lessons) .set({ title: data.title, @@ -68,7 +68,7 @@ export class AdminLessonRepository { } async getQuestions(conditions: any[]) { - return await this.db + return this.db .select() .from(questions) .where(and(...conditions)); @@ -77,7 +77,7 @@ export class AdminLessonRepository { async getQuestionAnswers(questionId: UUIDType, trx?: PostgresJsDatabase) { const dbInstance = trx ?? this.db; - return await dbInstance + return dbInstance .select({ id: questionAnswerOptions.id, optionText: questionAnswerOptions.optionText, @@ -118,7 +118,7 @@ export class AdminLessonRepository { async getQuestionAnswerOptions(questionId: UUIDType, trx?: PostgresJsDatabase) { const dbInstance = trx ?? this.db; - return await dbInstance + return dbInstance .select() .from(questionAnswerOptions) .where(eq(questionAnswerOptions.questionId, questionId)); @@ -140,7 +140,7 @@ export class AdminLessonRepository { async getQuestionStudentAnswers(questionId: UUIDType, trx?: PostgresJsDatabase) { const dbInstance = trx ?? this.db; - return await dbInstance + return dbInstance .select() .from(studentQuestionAnswers) .where(eq(studentQuestionAnswers.questionId, questionId)); @@ -155,7 +155,7 @@ export class AdminLessonRepository { async updateLessonDisplayOrder(chapterId: UUIDType, trx?: PostgresJsDatabase) { const dbInstance = trx ?? this.db; - return await dbInstance.execute(sql` + return dbInstance.execute(sql` WITH ranked_chapters AS ( SELECT id, row_number() OVER (ORDER BY display_order) AS new_display_order FROM ${lessons} @@ -374,7 +374,7 @@ export class AdminLessonRepository { ) { const dbInstance = trx ?? this.db; - return await dbInstance + return dbInstance .delete(questionAnswerOptions) .where( and( @@ -395,7 +395,7 @@ export class AdminLessonRepository { trx?: PostgresJsDatabase, ) { const dbInstance = trx ?? this.db; - return await dbInstance + return dbInstance .insert(questionAnswerOptions) .values({ id: option.id, diff --git a/apps/api/src/lesson/adminLesson.service.ts b/apps/api/src/lesson/adminLesson.service.ts index e9c8cb86..a51410a6 100644 --- a/apps/api/src/lesson/adminLesson.service.ts +++ b/apps/api/src/lesson/adminLesson.service.ts @@ -13,7 +13,7 @@ import type { CreateQuizLessonBody, UpdateLessonBody, UpdateQuizLessonBody, -} from "./lesson.schem"; +} from "./lesson.schema"; import type { UUIDType } from "src/common"; @Injectable() diff --git a/apps/api/src/lesson/lesson.controller.ts b/apps/api/src/lesson/lesson.controller.ts index ff96473b..0a7d5b7d 100644 --- a/apps/api/src/lesson/lesson.controller.ts +++ b/apps/api/src/lesson/lesson.controller.ts @@ -18,7 +18,7 @@ import { updateLessonSchema, UpdateQuizLessonBody, updateQuizLessonSchema, -} from "./lesson.schem"; +} from "./lesson.schema"; // import { // BetaFileLessonType, diff --git a/apps/api/src/lesson/lesson.repository.ts b/apps/api/src/lesson/lesson.repository.ts index d0658a58..f549f6a8 100644 --- a/apps/api/src/lesson/lesson.repository.ts +++ b/apps/api/src/lesson/lesson.repository.ts @@ -1,7 +1,9 @@ import { Inject, Injectable } from "@nestjs/common"; -import { and, eq } from "drizzle-orm"; +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 { @@ -14,9 +16,8 @@ import { DatabasePg, type UUIDType } from "src/common"; // studentCourses, // studentQuestionAnswers, // } from "src/storage/schema"; -import { chapters, lessons, 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() @@ -28,6 +29,44 @@ export class LessonRepository { 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({ diff --git a/apps/api/src/lesson/lesson.schem.ts b/apps/api/src/lesson/lesson.schem.ts deleted file mode 100644 index 55b97baa..00000000 --- a/apps/api/src/lesson/lesson.schem.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Type } from "@sinclair/typebox"; - -import { UUIDSchema } from "src/common"; - -import { PhotoQuestionType, QuestionType } from "./lesson.type"; - -import type { Static } from "@sinclair/typebox"; - -export const optionSchema = Type.Object({ - id: Type.Optional(UUIDSchema), - optionText: Type.String(), - isCorrect: Type.Boolean(), - position: Type.Number(), -}); - -export const questionSchema = Type.Object({ - id: Type.Optional(UUIDSchema), - type: Type.Enum(QuestionType), - description: Type.Optional(Type.String()), - title: Type.String(), - photoQuestionType: Type.Optional(Type.Enum(PhotoQuestionType)), - photoS3Key: Type.Optional(Type.String()), - options: Type.Optional(Type.Array(optionSchema)), -}); - -export const lessonSchema = Type.Object({ - updatedAt: Type.Optional(Type.String()), - id: UUIDSchema, - title: Type.String(), - type: Type.String(), - description: Type.String(), - displayOrder: Type.Number(), - fileS3Key: Type.Optional(Type.String()), - fileType: Type.Optional(Type.String()), - questions: Type.Optional(Type.Array(questionSchema)), -}); - -const lessonQuizSchema = Type.Object({ - id: UUIDSchema, - title: Type.String(), - type: Type.String(), - displayOrder: Type.Number(), - description: Type.Optional(Type.String()), - fileS3Key: Type.Optional(Type.String()), - fileType: Type.Optional(Type.String()), - questions: Type.Optional(Type.Array(questionSchema)), -}); - -export const createLessonSchema = Type.Intersect([ - Type.Omit(lessonSchema, ["id", "displayOrder"]), - Type.Object({ - chapterId: UUIDSchema, - displayOrder: Type.Optional(Type.Number()), - }), -]); - -export const createQuizLessonSchema = Type.Intersect([ - Type.Omit(lessonQuizSchema, ["id", "displayOrder"]), - Type.Object({ - chapterId: UUIDSchema, - displayOrder: Type.Optional(Type.Number()), - }), -]); - -export const updateLessonSchema = Type.Partial(createLessonSchema); -export const updateQuizLessonSchema = Type.Partial(createQuizLessonSchema); - -export type CreateLessonBody = Static; -export type UpdateLessonBody = Static; -export type UpdateQuizLessonBody = Static; -export type CreateQuizLessonBody = Static; diff --git a/apps/api/src/lesson/lesson.schema.ts b/apps/api/src/lesson/lesson.schema.ts index 23305ead..d6bbdc6e 100644 --- a/apps/api/src/lesson/lesson.schema.ts +++ b/apps/api/src/lesson/lesson.schema.ts @@ -1,57 +1,49 @@ -import { type Static, Type } from "@sinclair/typebox"; +import { Type } from "@sinclair/typebox"; import { UUIDSchema } from "src/common"; -// export const lessonItemToAdd = Type.Object({ -// id: UUIDSchema, -// type: Type.Union(LESSON_TYPES.map((type) => Type.Literal(type))), -// displayOrder: Type.Number(), -// }); +import { PhotoQuestionType, QuestionType } from "./lesson.type"; -// export const lessonItemToRemove = Type.Object({ -// id: UUIDSchema, -// type: Type.Union(LESSON_TYPES.map((type) => Type.Literal(type))), -// }); +import type { Static } from "@sinclair/typebox"; -export const questionAnswerOptionsSchema = Type.Object({ +export const optionSchema = Type.Object({ id: Type.Optional(UUIDSchema), optionText: Type.String(), - position: Type.Union([Type.Number(), Type.Null()]), - isStudentAnswer: Type.Optional(Type.Boolean()), - isCorrect: Type.Optional(Type.Boolean()), - questionId: UUIDSchema, + isCorrect: Type.Boolean(), + position: Type.Number(), }); export const questionSchema = Type.Object({ - id: UUIDSchema, - questionType: Type.String(), - questionBody: Type.String(), + id: Type.Optional(UUIDSchema), + type: Type.Enum(QuestionType), + description: Type.Optional(Type.String()), + title: Type.String(), + photoQuestionType: Type.Optional(Type.Enum(PhotoQuestionType)), photoS3Key: Type.Optional(Type.String()), - solutionExplanation: Type.Optional(Type.Union([Type.String(), Type.Null()])), - state: Type.Optional(Type.String()), - questionAnswers: Type.Optional(Type.Array(questionAnswerOptionsSchema)), - archived: Type.Optional(Type.Boolean()), + options: Type.Optional(Type.Array(optionSchema)), }); -export const textBlockSchema = Type.Object({ +export const lessonSchema = Type.Object({ + updatedAt: Type.Optional(Type.String()), id: UUIDSchema, title: Type.String(), - body: Type.Union([Type.String(), Type.Null()]), - state: Type.Optional(Type.String()), - archived: Type.Optional(Type.Boolean()), - authorId: UUIDSchema, - lessonId: Type.Optional(Type.String()), + type: Type.String(), + description: Type.String(), + displayOrder: Type.Number(), + fileS3Key: Type.Optional(Type.String()), + fileType: Type.Optional(Type.String()), + questions: Type.Optional(Type.Array(questionSchema)), }); -export const lessonItemFileSchema = Type.Object({ +const lessonQuizSchema = Type.Object({ id: UUIDSchema, title: Type.String(), type: Type.String(), - url: Type.String(), - authorId: UUIDSchema, - body: Type.Union([Type.String(), Type.Null()]), - state: Type.Optional(Type.String()), - archived: Type.Optional(Type.Boolean()), + displayOrder: Type.Number(), + description: Type.Optional(Type.String()), + fileS3Key: Type.Optional(Type.String()), + fileType: Type.Optional(Type.String()), + questions: Type.Optional(Type.Array(questionSchema)), }); export const lessonItemSchema = Type.Object({ @@ -65,211 +57,29 @@ export const lessonItemSchema = Type.Object({ questions: Type.Optional(Type.Array(questionSchema)), }); -export const lessonItemWithContent = Type.Object({ - ...lessonItemSchema.properties, - isCompleted: Type.Boolean(), - questionData: Type.Union([questionSchema, Type.Null()]), - textBlockData: Type.Union([textBlockSchema, Type.Null()]), - fileData: Type.Union([lessonItemFileSchema, Type.Null()]), -}); - -export const questionAnswer = Type.Object({ - id: UUIDSchema, - optionText: Type.String(), - position: Type.Union([Type.Number(), Type.Null()]), - isStudentAnswer: Type.Union([Type.Boolean(), Type.Null()]), - isCorrect: Type.Union([Type.Boolean(), Type.Null()]), -}); - -export const questionAnswerOptionsResponse = Type.Object({ - id: UUIDSchema, - optionText: Type.String(), - position: Type.Union([Type.Number(), Type.Null()]), - isStudentAnswer: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])), - isCorrect: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])), - studentAnswerText: Type.Optional(Type.Union([Type.String(), Type.Null()])), -}); - -export const questionResponse = Type.Object({ - id: UUIDSchema, - questionType: Type.String(), - questionBody: Type.String(), - solutionExplanation: Type.Optional(Type.Union([Type.String(), Type.Null()])), - questionAnswers: Type.Array(questionAnswerOptionsResponse), - passQuestion: Type.Union([Type.Boolean(), Type.Null()]), -}); - -export const questionContentResponse = Type.Object({ - id: UUIDSchema, - questionType: Type.String(), - questionBody: Type.String(), - questionAnswers: Type.Array(questionAnswerOptionsResponse), - solutionExplanation: Type.Optional(Type.Union([Type.String(), Type.Null()])), - passQuestion: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])), -}); - -export const textBlockContentResponse = Type.Object({ - id: UUIDSchema, - title: Type.String(), - body: Type.String(), - state: Type.String(), -}); - -export const fileContentResponse = Type.Object({ - id: UUIDSchema, - title: Type.String(), - type: Type.String(), - url: Type.String(), - body: Type.Union([Type.String(), Type.Null()]), -}); - -export const textBlockUpdateSchema = Type.Partial(Type.Omit(textBlockSchema, ["id"])); -export const questionUpdateSchema = Type.Partial(Type.Omit(questionSchema, ["id"])); -export const fileUpdateSchema = Type.Partial(Type.Omit(lessonItemFileSchema, ["id"])); - -export const lessonItemResponse = Type.Object({ - lessonItemId: UUIDSchema, - lessonItemType: Type.String(), - displayOrder: Type.Union([Type.Number(), Type.Null()]), - passQuestion: Type.Optional(Type.Union([Type.Null(), Type.Boolean()])), - content: Type.Union([questionContentResponse, textBlockContentResponse, fileContentResponse]), -}); - -export const questionWithContent = Type.Object({ - ...lessonItemSchema.properties, - questionData: questionSchema, -}); - -export const allLessonItemsResponse = Type.Array(lessonItemResponse); - -export const lessonItemSelectSchema = Type.Object({ - lessonItemId: UUIDSchema, - lessonItemType: Type.String(), - displayOrder: Type.Union([Type.Number(), Type.Null()]), - passQuestion: Type.Optional(Type.Union([Type.Null(), Type.Unknown()])), - isCompleted: Type.Optional(Type.Boolean()), - content: Type.Union([questionContentResponse, textBlockContentResponse, fileContentResponse]), -}); - -// export type LessonItemToAdd = Static; -// export type LessonItemToRemove = Static; -export type LessonItemResponse = Static; -export type QuestionAnswer = Static; -export type QuestionResponse = Static; -export type QuestionSchema = Static; -export type QuestionWithContent = Static; -export type LessonItemWithContentSchema = Static; -export type AllLessonItemsResponse = Static; - -export const textBlockSelectSchema = Type.Object({ - id: UUIDSchema, - title: Type.String(), - body: Type.Union([Type.String(), Type.Null()]), - state: Type.String(), - authorId: Type.String(), - archived: Type.Boolean(), - createdAt: Type.String(), - updatedAt: Type.String(), -}); - -export const questionSelectSchema = Type.Object({ - id: UUIDSchema, - questionType: Type.String(), - questionBody: Type.String(), - state: Type.String(), - authorId: Type.String(), - archived: Type.Boolean(), - createdAt: Type.String(), - updatedAt: Type.String(), - solutionExplanation: Type.Union([Type.String(), Type.Null()]), -}); - -export const fileSelectSchema = Type.Object({ - id: Type.String(), - title: Type.String(), - state: Type.String(), - authorId: Type.String(), - type: Type.String(), - url: Type.String(), - archived: Type.Boolean(), - createdAt: Type.String(), - updatedAt: Type.String(), -}); - -export const betaTextLessonSchema = Type.Object({ - title: Type.String(), - body: Type.String(), - state: Type.String(), - authorId: UUIDSchema, - lessonId: Type.String(), -}); - -export const betaFileSelectSchema = Type.Object({ - title: Type.String(), - state: Type.String(), - authorId: Type.String(), - type: Type.String(), - body: Type.Optional(Type.String()), - lessonId: Type.String(), -}); - -export type BetaTextLessonType = Static; -export type BetaFileLessonType = Static; - -export type UpdateTextBlockBody = Static; -export type UpdateQuestionBody = Static; -export type UpdateFileBody = Static; - -// TEMPORARY FIX -const BaseLessonItem = Type.Object({ - id: Type.String(), - state: Type.String(), - archived: Type.Boolean(), - authorId: Type.String(), - createdAt: Type.String(), - updatedAt: Type.String(), -}); - -const QuestionItem = Type.Intersect([ - BaseLessonItem, - Type.Object({ - itemType: Type.Literal("question"), - questionType: Type.String(), - questionBody: Type.String(), - solutionExplanation: Type.Union([Type.String(), Type.Null()]), - }), -]); - -const FileItem = Type.Intersect([ - BaseLessonItem, +export const createLessonSchema = Type.Intersect([ + Type.Omit(lessonSchema, ["id", "displayOrder"]), Type.Object({ - itemType: Type.Literal("file"), - title: Type.String(), - url: Type.String(), - body: Type.Union([Type.String(), Type.Null()]), - type: Type.String(), + chapterId: UUIDSchema, + displayOrder: Type.Optional(Type.Number()), }), ]); -const TextBlockItem = Type.Intersect([ - BaseLessonItem, +export const createQuizLessonSchema = Type.Intersect([ + Type.Omit(lessonQuizSchema, ["id", "displayOrder"]), Type.Object({ - itemType: Type.Literal("text_block"), - title: Type.String(), - body: Type.Union([Type.String(), Type.Null()]), + chapterId: UUIDSchema, + displayOrder: Type.Optional(Type.Number()), }), ]); -export const GetAllLessonItemsResponseSchema = Type.Array( - Type.Union([QuestionItem, FileItem, TextBlockItem]), -); +export const updateLessonSchema = Type.Partial(createLessonSchema); +export const updateQuizLessonSchema = Type.Partial(createQuizLessonSchema); -export const GetSingleLessonItemsResponseSchema = Type.Union([ - QuestionItem, - FileItem, - TextBlockItem, -]); - -export type GetAllLessonItemsResponse = Static; - -export type GetSingleLessonItemsResponse = Static; +export type LessonItemWithContentSchema = Static; +export type CreateLessonBody = Static; +export type UpdateLessonBody = Static; +export type UpdateQuizLessonBody = Static; +export type CreateQuizLessonBody = Static; +export type QuestionBody = Static; +export type QuestionSchema = Static; diff --git a/apps/api/src/studentLessonProgress/schemas/studentCompletedLesson.schema.ts b/apps/api/src/studentLessonProgress/schemas/studentCompletedLesson.schema.ts deleted file mode 100644 index 7bc2ef82..00000000 --- a/apps/api/src/studentLessonProgress/schemas/studentCompletedLesson.schema.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const studentCoursesStates = { - not_started: "not_started", - in_progress: "in_progress", - completed: "completed", -} as const; diff --git a/apps/api/src/studentLessonProgress/studentLessonProgress.service.ts b/apps/api/src/studentLessonProgress/studentLessonProgress.service.ts index be463716..fad7a177 100644 --- a/apps/api/src/studentLessonProgress/studentLessonProgress.service.ts +++ b/apps/api/src/studentLessonProgress/studentLessonProgress.service.ts @@ -11,8 +11,7 @@ import { studentCourses, studentLessonProgress, } from "src/storage/schema"; - -import { studentCoursesStates } from "./schemas/studentCompletedLesson.schema"; +import { PROGRESS_STATUSES } from "src/utils/types/progress.type"; import type { UUIDType } from "src/common"; @@ -71,18 +70,18 @@ export class StudentLessonProgressService { await this.updateStudentCourseStats( studentId, courseId, - studentCoursesStates.completed, + PROGRESS_STATUSES.COMPLETED, courseFinishedChapterCount, ); return await this.statisticsRepository.updateCompletedAsFreemiumCoursesStats(courseId); } - if (courseCompleted.state !== studentCoursesStates.in_progress) { + if (courseCompleted.state !== PROGRESS_STATUSES.IN_PROGRESS) { return await this.updateStudentCourseStats( studentId, courseId, - studentCoursesStates.in_progress, + PROGRESS_STATUSES.IN_PROGRESS, courseFinishedChapterCount, ); } diff --git a/apps/api/src/swagger/api-schema.json b/apps/api/src/swagger/api-schema.json index 7d153aee..61a14fa1 100644 --- a/apps/api/src/swagger/api-schema.json +++ b/apps/api/src/swagger/api-schema.json @@ -1027,6 +1027,15 @@ "get": { "operationId": "CourseController_getStudentCourses", "parameters": [ + { + "name": "excludeCourseId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "title", "required": false, @@ -1272,6 +1281,15 @@ } ] } + }, + { + "name": "excludeCourseId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } } ], "responses": { @@ -1299,6 +1317,36 @@ "format": "uuid", "type": "string" } + }, + { + "name": "scope", + "required": false, + "in": "query", + "schema": { + "anyOf": [ + { + "const": "all", + "type": "string" + }, + { + "const": "enrolled", + "type": "string" + }, + { + "const": "available", + "type": "string" + } + ] + } + }, + { + "name": "excludeCourseId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } } ], "responses": { @@ -1733,6 +1781,58 @@ } } }, + "/api/chapter": { + "get": { + "operationId": "ChapterController_getLesson", + "parameters": [ + { + "name": "id", + "required": true, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetLessonResponse" + } + } + } + } + } + }, + "delete": { + "operationId": "ChapterController_removeChapter", + "parameters": [ + { + "name": "chapterId", + "required": true, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoveChapterResponse" + } + } + } + } + } + } + }, "/api/chapter/beta-create-chapter": { "post": { "operationId": "ChapterController_betaCreateChapter", @@ -1787,33 +1887,6 @@ } } }, - "/api/chapter": { - "delete": { - "operationId": "ChapterController_removeChapter", - "parameters": [ - { - "name": "chapterId", - "required": true, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RemoveChapterResponse" - } - } - } - } - } - } - }, "/api/chapter/freemium-status": { "patch": { "operationId": "ChapterController_updateFreemiumStatus", @@ -2445,6 +2518,26 @@ "data": { "type": "object", "properties": { + "firstName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lastName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "id": { "format": "uuid", "type": "string" @@ -2491,6 +2584,8 @@ } }, "required": [ + "firstName", + "lastName", "id", "description", "contactEmail", @@ -3471,17 +3566,11 @@ "data": { "type": "object", "properties": { - "id": { - "format": "uuid", - "type": "string" - }, - "title": { - "type": "string" - }, - "thumbnailUrl": { - "type": "string" + "archived": { + "type": "boolean" }, - "description": { + "authorId": { + "format": "uuid", "type": "string" }, "category": { @@ -3491,38 +3580,6 @@ "format": "uuid", "type": "string" }, - "authorId": { - "format": "uuid", - "type": "string" - }, - "author": { - "type": "string" - }, - "authorEmail": { - "type": "string" - }, - "courseChapterCount": { - "type": "number" - }, - "completedChapterCount": { - "type": "number" - }, - "enrolled": { - "type": "boolean" - }, - "isPublished": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "isScorm": { - "type": "boolean" - }, "chapters": { "type": "array", "items": { @@ -3572,40 +3629,76 @@ "createdAt": { "type": "string" }, - "quizScore": { + "quizCount": { + "type": "number" + }, + "displayOrder": { "type": "number" } }, "required": [ "id", "title", - "lessonCount" + "lessonCount", + "displayOrder" ] } }, - "priceInCents": { + "completedChapterCount": { + "type": "number" + }, + "courseChapterCount": { "type": "number" }, "currency": { "type": "string" }, - "archived": { + "description": { + "type": "string" + }, + "enrolled": { "type": "boolean" }, "hasFreeChapter": { "type": "boolean" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "isPublished": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "isScorm": { + "type": "boolean" + }, + "priceInCents": { + "type": "number" + }, + "thumbnailUrl": { + "type": "string" + }, + "title": { + "type": "string" } }, "required": [ - "id", - "title", - "description", "category", + "chapters", "courseChapterCount", + "currency", + "description", + "id", "isPublished", - "chapters", "priceInCents", - "currency" + "title" ] } }, @@ -3619,17 +3712,11 @@ "data": { "type": "object", "properties": { - "id": { - "format": "uuid", - "type": "string" - }, - "title": { - "type": "string" - }, - "thumbnailUrl": { - "type": "string" + "archived": { + "type": "boolean" }, - "description": { + "authorId": { + "format": "uuid", "type": "string" }, "category": { @@ -3639,38 +3726,6 @@ "format": "uuid", "type": "string" }, - "authorId": { - "format": "uuid", - "type": "string" - }, - "author": { - "type": "string" - }, - "authorEmail": { - "type": "string" - }, - "courseChapterCount": { - "type": "number" - }, - "completedChapterCount": { - "type": "number" - }, - "enrolled": { - "type": "boolean" - }, - "isPublished": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "isScorm": { - "type": "boolean" - }, "chapters": { "type": "array", "items": { @@ -3720,40 +3775,76 @@ "createdAt": { "type": "string" }, - "quizScore": { + "quizCount": { + "type": "number" + }, + "displayOrder": { "type": "number" } }, "required": [ "id", "title", - "lessonCount" + "lessonCount", + "displayOrder" ] } }, - "priceInCents": { + "completedChapterCount": { + "type": "number" + }, + "courseChapterCount": { "type": "number" }, "currency": { "type": "string" }, - "archived": { + "description": { + "type": "string" + }, + "enrolled": { "type": "boolean" }, "hasFreeChapter": { "type": "boolean" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "isPublished": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "isScorm": { + "type": "boolean" + }, + "priceInCents": { + "type": "number" + }, + "thumbnailUrl": { + "type": "string" + }, + "title": { + "type": "string" } }, "required": [ - "id", - "title", - "description", "category", + "chapters", "courseChapterCount", + "currency", + "description", + "id", "isPublished", - "chapters", "priceInCents", - "currency" + "title" ] } }, @@ -3767,17 +3858,11 @@ "data": { "type": "object", "properties": { - "id": { - "format": "uuid", - "type": "string" - }, - "title": { - "type": "string" - }, - "thumbnailUrl": { - "type": "string" + "archived": { + "type": "boolean" }, - "description": { + "authorId": { + "format": "uuid", "type": "string" }, "category": { @@ -3787,38 +3872,6 @@ "format": "uuid", "type": "string" }, - "authorId": { - "format": "uuid", - "type": "string" - }, - "author": { - "type": "string" - }, - "authorEmail": { - "type": "string" - }, - "courseChapterCount": { - "type": "number" - }, - "completedChapterCount": { - "type": "number" - }, - "enrolled": { - "type": "boolean" - }, - "isPublished": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "isScorm": { - "type": "boolean" - }, "chapters": { "type": "array", "items": { @@ -3868,40 +3921,76 @@ "createdAt": { "type": "string" }, - "quizScore": { + "quizCount": { + "type": "number" + }, + "displayOrder": { "type": "number" } }, "required": [ "id", "title", - "lessonCount" + "lessonCount", + "displayOrder" ] } }, - "priceInCents": { + "completedChapterCount": { + "type": "number" + }, + "courseChapterCount": { "type": "number" }, "currency": { "type": "string" }, - "archived": { + "description": { + "type": "string" + }, + "enrolled": { "type": "boolean" }, "hasFreeChapter": { "type": "boolean" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "isPublished": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "isScorm": { + "type": "boolean" + }, + "priceInCents": { + "type": "number" + }, + "thumbnailUrl": { + "type": "string" + }, + "title": { + "type": "string" } }, "required": [ - "id", - "title", - "description", "category", + "chapters", "courseChapterCount", + "currency", + "description", + "id", "isPublished", - "chapters", "priceInCents", - "currency" + "title" ] } }, @@ -4767,6 +4856,211 @@ "data" ] }, + "GetLessonResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "title": { + "type": "string" + }, + "lessonCount": { + "type": "number" + }, + "completedLessonCount": { + "type": "number" + }, + "chapterProgress": { + "anyOf": [ + { + "const": "completed", + "type": "string" + }, + { + "const": "in_progress", + "type": "string" + }, + { + "const": "not_started", + "type": "string" + } + ] + }, + "isFreemium": { + "type": "boolean" + }, + "enrolled": { + "type": "boolean" + }, + "isPublished": { + "type": "boolean" + }, + "isSubmitted": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "quizCount": { + "type": "number" + }, + "displayOrder": { + "type": "number" + }, + "lessons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "updatedAt": { + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "displayOrder": { + "type": "number" + }, + "fileS3Key": { + "type": "string" + }, + "fileType": { + "type": "string" + }, + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "type": { + "anyOf": [ + { + "const": "single_choice", + "type": "string" + }, + { + "const": "multiple_choice", + "type": "string" + }, + { + "const": "true_or_false", + "type": "string" + }, + { + "const": "photo_question", + "type": "string" + }, + { + "const": "fill_in_the_blanks", + "type": "string" + }, + { + "const": "brief_response", + "type": "string" + }, + { + "const": "detailed_response", + "type": "string" + } + ] + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "photoQuestionType": { + "anyOf": [ + { + "const": "single_choice", + "type": "string" + }, + { + "const": "multiple_choice", + "type": "string" + } + ] + }, + "photoS3Key": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "optionText": { + "type": "string" + }, + "isCorrect": { + "type": "boolean" + }, + "position": { + "type": "number" + } + }, + "required": [ + "optionText", + "isCorrect", + "position" + ] + } + } + }, + "required": [ + "type", + "title" + ] + } + } + }, + "required": [ + "id", + "title", + "type", + "description", + "displayOrder" + ] + } + } + }, + "required": [ + "id", + "title", + "lessonCount", + "displayOrder", + "lessons" + ] + } + }, + "required": [ + "data" + ] + }, "BetaCreateChapterBody": { "type": "object", "allOf": [ @@ -4807,7 +5101,7 @@ "createdAt": { "type": "string" }, - "quizScore": { + "quizCount": { "type": "number" } }, diff --git a/apps/api/src/user/schemas/user.schema.ts b/apps/api/src/user/schemas/user.schema.ts index fc35ee0a..d62d217f 100644 --- a/apps/api/src/user/schemas/user.schema.ts +++ b/apps/api/src/user/schemas/user.schema.ts @@ -1,9 +1,11 @@ -import { Type, type Static } from "@sinclair/typebox"; +import { type Static, Type } from "@sinclair/typebox"; import { commonUserSchema } from "src/common/schemas/common-user.schema"; export const allUsersSchema = Type.Array(commonUserSchema); export const userDetailsSchema = Type.Object({ + firstName: Type.Union([Type.String(), Type.Null()]), + lastName: Type.Union([Type.String(), Type.Null()]), id: Type.String({ format: "uuid" }), description: Type.Union([Type.String(), Type.Null()]), contactEmail: Type.Union([Type.String(), Type.Null()]), diff --git a/apps/api/src/user/user.service.ts b/apps/api/src/user/user.service.ts index aadbf7f9..97a79d10 100644 --- a/apps/api/src/user/user.service.ts +++ b/apps/api/src/user/user.service.ts @@ -92,6 +92,8 @@ export class UserService { public async getUserDetails(userId: string): Promise { const [userBio]: UserDetails[] = await this.db .select({ + firstName: users.firstName, + lastName: users.lastName, id: userDetails.id, description: userDetails.description, contactEmail: userDetails.contactEmail, @@ -99,6 +101,7 @@ export class UserService { jobTitle: userDetails.jobTitle, }) .from(userDetails) + .leftJoin(users, eq(userDetails.userId, users.id)) .where(eq(userDetails.userId, userId)); if (!userBio) { diff --git a/apps/api/src/utils/types/progress.type.ts b/apps/api/src/utils/types/progress.type.ts new file mode 100644 index 00000000..ad8aabc3 --- /dev/null +++ b/apps/api/src/utils/types/progress.type.ts @@ -0,0 +1,7 @@ +export const PROGRESS_STATUSES = { + NOT_STARTED: "not_started", + IN_PROGRESS: "in_progress", + COMPLETED: "completed", +} as const; + +export type ProgressStatus = (typeof PROGRESS_STATUSES)[keyof typeof PROGRESS_STATUSES]; diff --git a/apps/api/src/utils/types/test-types.ts b/apps/api/src/utils/types/test-types.ts index 0586e1b2..b678b66e 100644 --- a/apps/api/src/utils/types/test-types.ts +++ b/apps/api/src/utils/types/test-types.ts @@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox"; import { chapterSchema } from "src/chapter/schemas/chapter.schema"; import { baseCourseSchema } from "src/courses/schemas/createCourse.schema"; -import { lessonSchema } from "src/lesson/lesson.schem"; +import { lessonSchema } from "src/lesson/lesson.schema"; import type { Static } from "@sinclair/typebox"; diff --git a/apps/web/app/api/generated-api.ts b/apps/web/app/api/generated-api.ts index bc4b44f5..9c4f1940 100644 --- a/apps/web/app/api/generated-api.ts +++ b/apps/web/app/api/generated-api.ts @@ -432,23 +432,12 @@ export interface GetTeacherCoursesResponse { export interface GetCourseResponse { data: { + archived?: boolean; /** @format uuid */ - id: string; - title: string; - thumbnailUrl?: string; - description: string; + authorId?: string; category: string; /** @format uuid */ categoryId?: string; - /** @format uuid */ - authorId?: string; - author?: string; - authorEmail?: string; - courseChapterCount: number; - completedChapterCount?: number; - enrolled?: boolean; - isPublished: boolean | null; - isScorm?: boolean; chapters: { /** @format uuid */ id: string; @@ -461,34 +450,33 @@ export interface GetCourseResponse { isPublished?: boolean; isSubmitted?: boolean; createdAt?: string; - quizScore?: number; + quizCount?: number; + displayOrder: number; }[]; - priceInCents: number; + completedChapterCount?: number; + courseChapterCount: number; currency: string; - archived?: boolean; + description: string; + enrolled?: boolean; hasFreeChapter?: boolean; + /** @format uuid */ + id: string; + isPublished: boolean | null; + isScorm?: boolean; + priceInCents: number; + thumbnailUrl?: string; + title: string; }; } export interface GetCourseByIdResponse { data: { + archived?: boolean; /** @format uuid */ - id: string; - title: string; - thumbnailUrl?: string; - description: string; + authorId?: string; category: string; /** @format uuid */ categoryId?: string; - /** @format uuid */ - authorId?: string; - author?: string; - authorEmail?: string; - courseChapterCount: number; - completedChapterCount?: number; - enrolled?: boolean; - isPublished: boolean | null; - isScorm?: boolean; chapters: { /** @format uuid */ id: string; @@ -501,34 +489,33 @@ export interface GetCourseByIdResponse { isPublished?: boolean; isSubmitted?: boolean; createdAt?: string; - quizScore?: number; + quizCount?: number; + displayOrder: number; }[]; - priceInCents: number; + completedChapterCount?: number; + courseChapterCount: number; currency: string; - archived?: boolean; + description: string; + enrolled?: boolean; hasFreeChapter?: boolean; + /** @format uuid */ + id: string; + isPublished: boolean | null; + isScorm?: boolean; + priceInCents: number; + thumbnailUrl?: string; + title: string; }; } export interface GetBetaCourseByIdResponse { data: { + archived?: boolean; /** @format uuid */ - id: string; - title: string; - thumbnailUrl?: string; - description: string; + authorId?: string; category: string; /** @format uuid */ categoryId?: string; - /** @format uuid */ - authorId?: string; - author?: string; - authorEmail?: string; - courseChapterCount: number; - completedChapterCount?: number; - enrolled?: boolean; - isPublished: boolean | null; - isScorm?: boolean; chapters: { /** @format uuid */ id: string; @@ -541,12 +528,22 @@ export interface GetBetaCourseByIdResponse { isPublished?: boolean; isSubmitted?: boolean; createdAt?: string; - quizScore?: number; + quizCount?: number; + displayOrder: number; }[]; - priceInCents: number; + completedChapterCount?: number; + courseChapterCount: number; currency: string; - archived?: boolean; + description: string; + enrolled?: boolean; hasFreeChapter?: boolean; + /** @format uuid */ + id: string; + isPublished: boolean | null; + isScorm?: boolean; + priceInCents: number; + thumbnailUrl?: string; + title: string; }; } @@ -792,6 +789,58 @@ export interface UpdateLessonDisplayOrderResponse { }; } +export interface GetLessonResponse { + data: { + /** @format uuid */ + id: string; + title: string; + lessonCount: number; + completedLessonCount?: number; + chapterProgress?: "completed" | "in_progress" | "not_started"; + isFreemium?: boolean; + enrolled?: boolean; + isPublished?: boolean; + isSubmitted?: boolean; + createdAt?: string; + quizCount?: number; + displayOrder: number; + lessons: { + updatedAt?: string; + /** @format uuid */ + id: string; + title: string; + type: string; + description: string; + displayOrder: number; + fileS3Key?: string; + fileType?: string; + questions?: { + /** @format uuid */ + id?: string; + type: + | "single_choice" + | "multiple_choice" + | "true_or_false" + | "photo_question" + | "fill_in_the_blanks" + | "brief_response" + | "detailed_response"; + description?: string; + title: string; + photoQuestionType?: "single_choice" | "multiple_choice"; + photoS3Key?: string; + options?: { + /** @format uuid */ + id?: string; + optionText: string; + isCorrect: boolean; + position: number; + }[]; + }[]; + }[]; + }; +} + export type BetaCreateChapterBody = { title: string; chapterProgress?: "completed" | "in_progress" | "not_started"; @@ -800,7 +849,7 @@ export type BetaCreateChapterBody = { isPublished?: boolean; isSubmitted?: boolean; createdAt?: string; - quizScore?: number; + quizCount?: number; } & { /** @format uuid */ courseId: string; @@ -1698,6 +1747,9 @@ export class API extends HttpClient @@ -2001,6 +2053,48 @@ export class API extends HttpClient + this.request({ + path: `/api/chapter`, + method: "GET", + query: query, + format: "json", + ...params, + }), + + /** + * No description + * + * @name ChapterControllerRemoveChapter + * @request DELETE:/api/chapter + */ + chapterControllerRemoveChapter: ( + query: { + /** @format uuid */ + chapterId: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/chapter`, + method: "DELETE", + query: query, + format: "json", + ...params, + }), + /** * No description * @@ -2036,27 +2130,6 @@ export class API extends HttpClient - this.request({ - path: `/api/chapter`, - method: "DELETE", - query: query, - format: "json", - ...params, - }), - /** * No description * diff --git a/apps/web/app/api/queries/useAvailableCourses.ts b/apps/web/app/api/queries/useAvailableCourses.ts index 944dc7de..a05b03ac 100644 --- a/apps/web/app/api/queries/useAvailableCourses.ts +++ b/apps/web/app/api/queries/useAvailableCourses.ts @@ -10,6 +10,7 @@ type CourseParams = { category?: string; sort?: SortOption; userId?: string; + excludeCourseId?: string; }; export const availableCoursesQueryOptions = (searchParams?: CourseParams) => ({ @@ -22,6 +23,7 @@ export const availableCoursesQueryOptions = (searchParams?: CourseParams) => ({ ...(searchParams?.category && { category: searchParams.category }), ...(searchParams?.sort && { sort: searchParams.sort }), ...(searchParams?.userId && { userId: searchParams.userId }), + ...(searchParams?.excludeCourseId && { excludeCourseId: searchParams.excludeCourseId }), }); return response.data; }, diff --git a/apps/web/app/api/queries/useTeacherCourses.ts b/apps/web/app/api/queries/useTeacherCourses.ts index c82cddbe..bef59299 100644 --- a/apps/web/app/api/queries/useTeacherCourses.ts +++ b/apps/web/app/api/queries/useTeacherCourses.ts @@ -4,11 +4,25 @@ import { ApiClient } from "../api-client"; import type { GetTeacherCoursesResponse } from "../generated-api"; -export const teacherCourses = (authorId: string) => { +type SearchParams = { + scope?: "all" | "enrolled" | "available"; + excludeCourseId?: string; +}; + +export const teacherCoursesOptions = (authorId?: string, searchParams?: SearchParams) => { return { + enabled: !!authorId, queryKey: ["teacher-courses", authorId], queryFn: async () => { - const response = await ApiClient.api.courseControllerGetTeacherCourses({ authorId }); + if (!authorId) { + throw new Error("Author ID is required"); + } + + const response = await ApiClient.api.courseControllerGetTeacherCourses({ + authorId, + ...(searchParams?.scope && { scope: searchParams.scope }), + ...(searchParams?.excludeCourseId && { excludeCourseId: searchParams.excludeCourseId }), + }); return response.data; }, @@ -16,6 +30,6 @@ export const teacherCourses = (authorId: string) => { }; }; -export function useTeacherCourses(authorId: string) { - return useQuery(teacherCourses(authorId)); +export function useTeacherCourses(authorId?: string, searchParams?: SearchParams) { + return useQuery(teacherCoursesOptions(authorId, searchParams)); } diff --git a/apps/web/app/api/queries/useUserDetails.ts b/apps/web/app/api/queries/useUserDetails.ts index fabe899d..d2a4e557 100644 --- a/apps/web/app/api/queries/useUserDetails.ts +++ b/apps/web/app/api/queries/useUserDetails.ts @@ -4,10 +4,14 @@ import { ApiClient } from "../api-client"; import type { GetUserDetailsResponse } from "../generated-api"; -export const userDetails = (userId: string) => { +export const userDetails = (userId?: string) => { return { + enabled: !!userId, queryKey: ["user-details", userId], queryFn: async () => { + if (!userId) { + throw new Error("userId is required"); + } const response = await ApiClient.api.userControllerGetUserDetails({ userId }); return response.data; @@ -16,10 +20,10 @@ export const userDetails = (userId: string) => { }; }; -export function useUserDetails(userId: string) { +export function useUserDetails(userId?: string) { return useQuery(userDetails(userId)); } -export function useUserDetailsSuspense(userId: string) { +export function useUserDetailsSuspense(userId?: string) { return useSuspenseQuery(userDetails(userId)); } diff --git a/apps/web/app/assets/svgs/actions/index.ts b/apps/web/app/assets/svgs/actions/index.ts new file mode 100644 index 00000000..122d123c --- /dev/null +++ b/apps/web/app/assets/svgs/actions/index.ts @@ -0,0 +1,2 @@ +export { default as Share } from "./share.svg?react"; +export { default as Play } from "./play.svg?react"; diff --git a/apps/web/app/assets/svgs/actions/play.svg b/apps/web/app/assets/svgs/actions/play.svg new file mode 100644 index 00000000..17c0d13f --- /dev/null +++ b/apps/web/app/assets/svgs/actions/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/app/assets/svgs/actions/share.svg b/apps/web/app/assets/svgs/actions/share.svg new file mode 100644 index 00000000..32a56df5 --- /dev/null +++ b/apps/web/app/assets/svgs/actions/share.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/app/assets/svgs/arrow-down.svg b/apps/web/app/assets/svgs/arrows/arrow-down.svg similarity index 100% rename from apps/web/app/assets/svgs/arrow-down.svg rename to apps/web/app/assets/svgs/arrows/arrow-down.svg diff --git a/apps/web/app/assets/svgs/arrow-right.svg b/apps/web/app/assets/svgs/arrows/arrow-right.svg similarity index 100% rename from apps/web/app/assets/svgs/arrow-right.svg rename to apps/web/app/assets/svgs/arrows/arrow-right.svg diff --git a/apps/web/app/assets/svgs/arrow-up.svg b/apps/web/app/assets/svgs/arrows/arrow-up.svg similarity index 100% rename from apps/web/app/assets/svgs/arrow-up.svg rename to apps/web/app/assets/svgs/arrows/arrow-up.svg diff --git a/apps/web/app/assets/svgs/carret-down.svg b/apps/web/app/assets/svgs/arrows/carret-down.svg similarity index 100% rename from apps/web/app/assets/svgs/carret-down.svg rename to apps/web/app/assets/svgs/arrows/carret-down.svg diff --git a/apps/web/app/assets/svgs/chevron-left.svg b/apps/web/app/assets/svgs/arrows/chevron-left.svg similarity index 100% rename from apps/web/app/assets/svgs/chevron-left.svg rename to apps/web/app/assets/svgs/arrows/chevron-left.svg diff --git a/apps/web/app/assets/svgs/chevron-right.svg b/apps/web/app/assets/svgs/arrows/chevron-right.svg similarity index 100% rename from apps/web/app/assets/svgs/chevron-right.svg rename to apps/web/app/assets/svgs/arrows/chevron-right.svg diff --git a/apps/web/app/assets/svgs/arrows/index.ts b/apps/web/app/assets/svgs/arrows/index.ts new file mode 100644 index 00000000..58e80abd --- /dev/null +++ b/apps/web/app/assets/svgs/arrows/index.ts @@ -0,0 +1,6 @@ +export { default as ArrowDown } from "./arrow-down.svg?react"; +export { default as ArrowRight } from "./arrow-right.svg?react"; +export { default as ArrowUp } from "./arrow-up.svg?react"; +export { default as CarretDown } from "./carret-down.svg?react"; +export { default as ChevronLeft } from "./chevron-left.svg?react"; +export { default as ChevronRight } from "./chevron-right.svg?react"; diff --git a/apps/web/app/assets/svgs/index.ts b/apps/web/app/assets/svgs/index.ts index a38524ba..534d4d6d 100644 --- a/apps/web/app/assets/svgs/index.ts +++ b/apps/web/app/assets/svgs/index.ts @@ -1,7 +1,4 @@ -export { default as ArrowRight } from "./arrow-right.svg?react"; -export { default as CaretDown } from "./carret-down.svg?react"; export { default as Category } from "./category.svg?react"; -export { default as CaretRight } from "./chevron-right.svg?react"; export { default as Course } from "./course.svg?react"; export { default as Dashboard } from "./dashboard.svg?react"; export { default as Directory } from "./directory.svg?react"; @@ -40,10 +37,7 @@ export { default as PhotoQuestion } from "./photo-question.svg?react"; export { default as TrueOrFalse } from "./true-or-false.svg?react"; export { default as Success } from "./success.svg?react"; export { default as CourseEmptyState } from "./course-empty-state.svg?react"; -export { default as ChevronLeft } from "./chevron-left.svg?react"; export { default as DragAndDropIcon } from "./drag-and-drop.svg?react"; -export { default as ArrowUp } from "./arrow-up.svg?react"; -export { default as ArrowDown } from "./arrow-down.svg?react"; export { default as Info } from "./info.svg?react"; export { default as Checkmark } from "./checkmark.svg?react"; export { default as Flame } from "./flame.svg?react"; @@ -51,3 +45,5 @@ export { default as FreeRight } from "./free-right.svg?react"; export { default as NoData } from "./no-data.svg?react"; export * from "./lesson-types"; +export * from "./arrows"; +export * from "./actions"; diff --git a/apps/web/app/assets/svgs/info.svg b/apps/web/app/assets/svgs/info.svg index e9cea311..b8404452 100644 --- a/apps/web/app/assets/svgs/info.svg +++ b/apps/web/app/assets/svgs/info.svg @@ -1,3 +1,3 @@ - - + + diff --git a/apps/web/app/assets/svgs/presentation.svg b/apps/web/app/assets/svgs/presentation.svg deleted file mode 100644 index 7b593ac0..00000000 --- a/apps/web/app/assets/svgs/presentation.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/app/assets/svgs/quiz.svg b/apps/web/app/assets/svgs/quiz.svg deleted file mode 100644 index f07c21eb..00000000 --- a/apps/web/app/assets/svgs/quiz.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/app/assets/svgs/text.svg b/apps/web/app/assets/svgs/text.svg deleted file mode 100644 index 5983c9df..00000000 --- a/apps/web/app/assets/svgs/text.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/app/assets/svgs/video.svg b/apps/web/app/assets/svgs/video.svg deleted file mode 100644 index 60556c2e..00000000 --- a/apps/web/app/assets/svgs/video.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/app/components/Badges/ProgressBadge.tsx b/apps/web/app/components/Badges/ProgressBadge.tsx new file mode 100644 index 00000000..a2acff14 --- /dev/null +++ b/apps/web/app/components/Badges/ProgressBadge.tsx @@ -0,0 +1,44 @@ +import { Badge } from "~/components/ui/badge"; + +import type { IconName } from "~/types/shared"; + +type ProgressBadgeProps = { + progress: "completed" | "inProgress" | "notStarted"; + className?: string; +}; + +type ProgressConfig = { + [key in "completed" | "inProgress" | "notStarted"]: { + variant: "successFilled" | "inProgressFilled" | "notStartedFilled"; + icon: IconName; + label: string; + }; +}; + +export const ProgressBadge = ({ progress, className }: ProgressBadgeProps) => { + const progressConfig: ProgressConfig = { + completed: { + variant: "successFilled", + icon: "InputRoundedMarkerSuccess", + label: "Completed", + }, + inProgress: { + variant: "inProgressFilled", + icon: "InProgress", + label: "In Progress", + }, + notStarted: { + variant: "notStartedFilled", + icon: "NotStartedRounded", + label: "Not Started", + }, + }; + + const { variant, icon, label } = progressConfig[progress]; + + return ( + + {label} + + ); +}; diff --git a/apps/web/app/components/CardBadge.tsx b/apps/web/app/components/CardBadge.tsx index b7d1ac6b..820ffabc 100644 --- a/apps/web/app/components/CardBadge.tsx +++ b/apps/web/app/components/CardBadge.tsx @@ -13,6 +13,7 @@ const badgeVariants = cva( default: "text-neutral-900", primary: "text-primary-950", secondary: "text-secondary-700", + secondaryFilled: "text-secondary-700 bg-secondary-50", successOutlined: "text-success-800", successFilled: "text-white bg-success-600", }, diff --git a/apps/web/app/components/CopyUrlButton/CopyUrlButton.tsx b/apps/web/app/components/CopyUrlButton/CopyUrlButton.tsx new file mode 100644 index 00000000..76be86f3 --- /dev/null +++ b/apps/web/app/components/CopyUrlButton/CopyUrlButton.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip"; + +import type { PropsWithChildren } from "react"; +import type { ButtonProps } from "~/components/ui/button"; + +type CopyUrlButtonProps = PropsWithChildren; + +export const CopyUrlButton = ({ children, ...props }: CopyUrlButtonProps) => { + const [isCopied, setIsCopied] = useState(false); + + const handleCopy = () => { + const currentUrl = window.location.href; + + navigator.clipboard + .writeText(currentUrl) + .then(() => { + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }) + .catch((err) => { + console.error("Failed to copy URL: ", err); + }); + }; + + return ( + + + + + + The URL address was copied to the clipboard + + + ); +}; diff --git a/apps/web/app/components/CopyUrlButton/index.ts b/apps/web/app/components/CopyUrlButton/index.ts new file mode 100644 index 00000000..5e12fe69 --- /dev/null +++ b/apps/web/app/components/CopyUrlButton/index.ts @@ -0,0 +1 @@ +export { CopyUrlButton } from "./CopyUrlButton"; diff --git a/apps/web/app/components/PageWrapper/PageWrapper.tsx b/apps/web/app/components/PageWrapper/PageWrapper.tsx index 2457ac01..fe0a0138 100644 --- a/apps/web/app/components/PageWrapper/PageWrapper.tsx +++ b/apps/web/app/components/PageWrapper/PageWrapper.tsx @@ -1,15 +1,53 @@ +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, +} from "~/components/ui/breadcrumb"; import { cn } from "~/lib/utils"; -import type { HTMLAttributes } from "react"; +import type { HTMLAttributes, ReactNode } from "react"; type PageWrapperProps = HTMLAttributes & { + breadcrumbs?: { title: string; href: string }[]; + children: ReactNode; className?: string; }; -export const PageWrapper = ({ className, ...props }: PageWrapperProps) => { +type Breadcrumb = { title: string; href: string }; + +type BreadcrumbsProps = { + breadcrumbs?: Breadcrumb[]; +}; + +export const Breadcrumbs = ({ breadcrumbs = [] }: BreadcrumbsProps) => { + if (!breadcrumbs.length) return null; + + return ( + + {breadcrumbs.map(({ href, title }, index) => ( + + {title} + {index < breadcrumbs.length - 1 && } + + ))} + + ); +}; + +export const PageWrapper = ({ className, breadcrumbs, children, ...props }: PageWrapperProps) => { + const hasBreadcrumbs = Boolean(breadcrumbs); + const classes = cn( - "h-auto w-full pt-6 px-4 pb-4 md:px-6 md:pb-6 2xl:pt-12 2xl:px-8 2xl:pb-8", + "w-full pt-6 px-4 pb-4 md:px-6 md:pb-6 3xl:pt-12 3xl:px-8 3xl:pb-8", + { "pt-8 md:pt-6 3xl:pb-2": hasBreadcrumbs }, className, ); - return
; + + return ( +
+ {breadcrumbs && } + {children} +
+ ); }; diff --git a/apps/web/app/components/SortableList/SortableList.tsx b/apps/web/app/components/SortableList/SortableList.tsx index 1b59fc58..ab081aba 100644 --- a/apps/web/app/components/SortableList/SortableList.tsx +++ b/apps/web/app/components/SortableList/SortableList.tsx @@ -15,7 +15,7 @@ interface BaseItem { interface SortableListProps { items: T[]; - onChange(items: T[], newPosition: number): void; + onChange(items: T[], newChapterPosition: number, newDisplayOrder: number): void; additionalOnChangeAction?(): void; renderItem(item: T): ReactNode; className?: string; @@ -59,11 +59,19 @@ export function SortableList({ const updatedItems = arrayMove(items, activeIndex, overIndex); - const updatedItem = updatedItems.find((item) => item.id === active.id); - const newPosition = updatedItems.indexOf(updatedItem!); + const updatedItemsWithOrder = updatedItems.map((item, index) => ({ + ...item, + displayOrder: index + 1, + })); - onChange(updatedItems, newPosition); + const updatedItem = updatedItemsWithOrder.find((item) => item.id === active.id); + + const newChapterPosition = updatedItemsWithOrder.indexOf(updatedItem!); + const newDisplayOrder = newChapterPosition + 1; + + onChange(updatedItemsWithOrder, newChapterPosition, newDisplayOrder); } + setActive(null); }} onDragCancel={() => { diff --git a/apps/web/app/components/ui/badge.tsx b/apps/web/app/components/ui/badge.tsx index 468242c2..fed1e86d 100644 --- a/apps/web/app/components/ui/badge.tsx +++ b/apps/web/app/components/ui/badge.tsx @@ -1,34 +1,52 @@ -import { cva, type VariantProps } from "class-variance-authority"; +import { cva } from "class-variance-authority"; +import { Icon } from "~/components/Icon"; import { cn } from "~/lib/utils"; -import type * as React from "react"; +import type { VariantProps } from "class-variance-authority"; +import type { HTMLAttributes } from "react"; +import type { IconName } from "~/types/shared"; const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + "flex shrink-0 items-center h-min text-sm font-medium rounded-lg px-2 py-1 gap-x-2", { variants: { variant: { - default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + default: "text-neutral-900 bg-white border border-neutral-200", + success: "text-success-800 bg-success-100", + successFilled: "text-success-800 bg-success-50", + inProgress: "text-warning-800 bg-warning-100", + inProgressFilled: "text-secondary-700 bg-secondary-50", + notStarted: "text-neutral-600 bg-neutral-100", + notStartedFilled: "text-neutral-900 bg-white border border-neutral-200", secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", outline: "text-foreground", }, + outline: { + true: "bg-transparent border border-current", + false: "", + }, }, defaultVariants: { variant: "default", + outline: false, }, }, ); -export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} - -function Badge({ className, variant, ...props }: BadgeProps) { - return
; -} +type BadgeProps = HTMLAttributes & + VariantProps & { + icon?: IconName; + }; -export { Badge, badgeVariants }; +export const Badge = ({ className, variant, outline, icon, children, ...props }: BadgeProps) => { + return ( +
+ {icon && } + {children} +
+ ); +}; diff --git a/apps/web/app/lib/utils.ts b/apps/web/app/lib/utils.ts index 365058ce..3f0e22df 100644 --- a/apps/web/app/lib/utils.ts +++ b/apps/web/app/lib/utils.ts @@ -4,3 +4,10 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function formatWithPlural(count: number, singular: string, plural: string) { + if (count === 0) return null; + + const pluralizedLabel = count === 1 ? singular : plural; + return `${count} ${pluralizedLabel}`; +} diff --git a/apps/web/app/modules/Admin/EditCourse/CourseLessons/components/ChaptersList.tsx b/apps/web/app/modules/Admin/EditCourse/CourseLessons/components/ChaptersList.tsx index e16e6cdc..019b96af 100644 --- a/apps/web/app/modules/Admin/EditCourse/CourseLessons/components/ChaptersList.tsx +++ b/apps/web/app/modules/Admin/EditCourse/CourseLessons/components/ChaptersList.tsx @@ -1,6 +1,7 @@ import * as Switch from "@radix-ui/react-switch"; import { useParams } from "@remix-run/react"; import { useCallback, useEffect, useState } from "react"; +import { flushSync } from "react-dom"; import { useChangeChapterDisplayOrder } from "~/api/mutations/admin/changeChapterDisplayOrder"; import { useUpdateLessonFreemiumStatus } from "~/api/mutations/admin/useUpdateLessonFreemiumStatus"; @@ -26,7 +27,7 @@ import { import { cn } from "~/lib/utils"; import { LessonCardList } from "~/modules/Admin/EditCourse/CourseLessons/components/LessonCardList"; -import { ContentTypes, LessonType } from "../../EditCourse.types"; +import { ContentTypes } from "../../EditCourse.types"; import type { Chapter, Lesson } from "../../EditCourse.types"; import type React from "react"; @@ -145,7 +146,7 @@ const ChapterCard = ({ - + { - setItems(updatedItems); + onChange={(updatedItems, newChapterPosition, newDisplayOrder) => { + flushSync(() => { + setItems(updatedItems); - mutation.mutate({ - chapter: { chapterId: updatedItems[newPosition].id, displayOrder: newPosition }, + mutation.mutate({ + chapter: { + chapterId: updatedItems[newChapterPosition].id, + displayOrder: newDisplayOrder, + }, + }); }); }} className="grid grid-cols-1" diff --git a/apps/web/app/modules/Admin/EditCourse/CourseLessons/components/LessonCardList.tsx b/apps/web/app/modules/Admin/EditCourse/CourseLessons/components/LessonCardList.tsx index d41a4383..57e83aa6 100644 --- a/apps/web/app/modules/Admin/EditCourse/CourseLessons/components/LessonCardList.tsx +++ b/apps/web/app/modules/Admin/EditCourse/CourseLessons/components/LessonCardList.tsx @@ -59,11 +59,11 @@ export const LessonCardList = ({ return ( { + onChange={async (updatedItems, newChapterPosition, newDisplayOrder) => { setItems(updatedItems); await mutation.mutateAsync({ - lesson: { lessonId: updatedItems[newPosition].id, displayOrder: newPosition }, + lesson: { lessonId: updatedItems[newChapterPosition].id, displayOrder: newDisplayOrder }, }); }} className="mt-4 grid grid-cols-1 gap-4" diff --git a/apps/web/app/modules/Courses/CourseView/LessonCard.tsx b/apps/web/app/modules/Courses/CourseView/ChapterCard.tsx similarity index 80% rename from apps/web/app/modules/Courses/CourseView/LessonCard.tsx rename to apps/web/app/modules/Courses/CourseView/ChapterCard.tsx index 278080af..a5150cba 100644 --- a/apps/web/app/modules/Courses/CourseView/LessonCard.tsx +++ b/apps/web/app/modules/Courses/CourseView/ChapterCard.tsx @@ -12,11 +12,11 @@ import { cn } from "~/lib/utils"; import type { GetCourseResponse } from "~/api/generated-api"; -type Lesson = GetCourseResponse["data"]["lessons"][number]; +type Chapter = GetCourseResponse["data"]["chapters"][number]; -type LessonStatus = "not_started" | "in_progress" | "completed"; +type ChapterStatus = "not_started" | "in_progress" | "completed"; -type LessonCardProps = Lesson & { +type ChapterCardProps = Chapter & { index: number; isEnrolled: boolean; isAdmin: boolean; @@ -37,22 +37,22 @@ const buttonVariants = cva("w-full transition", { }, }); -const getButtonProps = (lessonProgress: LessonStatus, isAdmin: boolean, type?: string) => { +const getButtonProps = (chapterProgress: ChapterStatus, isAdmin: boolean, type?: string) => { if (isAdmin) { return { text: "Lesson preview", colorClass: "text-primary-700" }; } - if (lessonProgress === "completed") { + if (chapterProgress === "completed") { return type === "quiz" ? { text: "Try again", colorClass: "text-success-600" } : { text: "Read more", colorClass: "text-success-600" }; } - if (lessonProgress === "in_progress") { + if (chapterProgress === "in_progress") { return { text: "Continue", colorClass: "text-secondary-500" }; } - if (lessonProgress === "not_started" && type === "quiz") { + if (chapterProgress === "not_started" && type === "quiz") { return { text: "Start", colorClass: "text-primary-700" }; } @@ -71,30 +71,30 @@ const cardBadgeVariant: Record { +}: ChapterCardProps) => { const cardClasses = buttonVariants({ className: cn({ "opacity-60 cursor-not-allowed hover:border-primary-200": !isEnrolled && !isFree, }), - variant: lessonProgress, + variant: chapterProgress, }); const { text: buttonText, colorClass: buttonColorClass } = getButtonProps( - lessonProgress, + chapterProgress, isAdmin, type, ); @@ -115,7 +115,7 @@ export const LessonCard = ({
{`Chapter - {lessonProgress && ( + {chapterProgress && ( - - {startCase(lessonProgress)} + + {startCase(chapterProgress)} )} @@ -140,10 +140,10 @@ export const LessonCard = ({
diff --git a/apps/web/app/modules/Courses/CourseView/CourseView.page.tsx b/apps/web/app/modules/Courses/CourseView/CourseView.page.tsx index 367ec0ff..ba57ce49 100644 --- a/apps/web/app/modules/Courses/CourseView/CourseView.page.tsx +++ b/apps/web/app/modules/Courses/CourseView/CourseView.page.tsx @@ -61,7 +61,7 @@ export default function CoursesViewPage() { > - Dashboard + Dashboard diff --git a/apps/web/app/modules/Courses/CourseView/LessonsList.tsx b/apps/web/app/modules/Courses/CourseView/LessonsList.tsx index 0669ed7a..1ca38145 100644 --- a/apps/web/app/modules/Courses/CourseView/LessonsList.tsx +++ b/apps/web/app/modules/Courses/CourseView/LessonsList.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { ButtonGroup } from "~/components/ButtonGroup/ButtonGroup"; import { useUserRole } from "~/hooks/useUserRole"; -import { LessonCard } from "~/modules/Courses/CourseView/LessonCard"; +import { ChapterCard } from "~/modules/Courses/CourseView/ChapterCard"; import type { GetCourseResponse } from "~/api/generated-api"; @@ -32,7 +32,7 @@ export const LessonsList = ({ lessons, isEnrolled }: LessonsListProps) => { const lessonCards = useMemo(() => { return filteredLessons?.map((lesson, index) => ( - { + const lessonText = formatWithPlural(chapter.lessonCount ?? 0, "Lesson", "Lessons"); + const quizText = formatWithPlural(chapter.quizCount ?? 0, "Quiz", "Quizzes"); + + return ( + + +
+
+
+ {!chapter.chapterProgress || chapter.chapterProgress === "not_started" ? ( + + 0{chapter.displayOrder} + + ) : ( + + )} +
+
+
+
+ +
+
+ +
+
+
+ {lessonText} {lessonText && quizText ? "- " : ""} {quizText} +
+

{chapter.title}

+
+ + {chapter.completedLessonCount}/{chapter.lessonCount} + + {Array.from({ length: chapter.lessonCount }).map((_, index) => { + if (!chapter.completedLessonCount) { + return ( + + ); + } + + if (chapter.completedLessonCount && index < chapter.completedLessonCount) { + return ( + + ); + } + + return ( + + ); + })} +
+
+ {chapter.isFreemium && ( + + + Free + + )} +
+
+ +
+ {chapter?.lessons?.map((lesson) => { + if (!lesson) return null; + + return ; + })} + +
+
+
+
+ + + ); +}; diff --git a/apps/web/app/modules/Courses/NewCourseView/CourseChapterLesson.tsx b/apps/web/app/modules/Courses/NewCourseView/CourseChapterLesson.tsx new file mode 100644 index 00000000..d3cec93c --- /dev/null +++ b/apps/web/app/modules/Courses/NewCourseView/CourseChapterLesson.tsx @@ -0,0 +1,35 @@ +import { ProgressBadge } from "~/components/Badges/ProgressBadge"; +import { Icon } from "~/components/Icon"; +import { LessonTypes, LessonTypesIcons } from "~/modules/Courses/NewCourseView/lessonTypes"; + +import type { GetCourseResponse } from "~/api/generated-api"; + +const progressBadge = { + completed: "completed", + in_progress: "inProgress", + not_started: "notStarted", +} as const; + +type Lesson = GetCourseResponse["data"]["chapters"][number]["lessons"][number]; + +type CourseChapterLessonProps = { + lesson: Lesson; +}; + +export const CourseChapterLesson = ({ lesson }: CourseChapterLessonProps) => { + return ( +
+ +
+

+ {lesson.title}{" "} + + {lesson.quizQuestionCount ? `(${lesson.quizQuestionCount})` : null} + +

+ {LessonTypes[lesson.type]} +
+ +
+ ); +}; diff --git a/apps/web/app/modules/Courses/NewCourseView/CourseOverview.tsx b/apps/web/app/modules/Courses/NewCourseView/CourseOverview.tsx new file mode 100644 index 00000000..3afe39f9 --- /dev/null +++ b/apps/web/app/modules/Courses/NewCourseView/CourseOverview.tsx @@ -0,0 +1,40 @@ +import CardPlaceholder from "~/assets/placeholders/card-placeholder.jpg"; +import Viewer from "~/components/RichText/Viever"; +import { Card, CardContent } from "~/components/ui/card"; +import { CategoryChip } from "~/components/ui/CategoryChip"; + +import type { GetCourseResponse } from "~/api/generated-api"; + +type CourseOverviewProps = { + course: GetCourseResponse["data"]; +}; + +export default function CourseOverview({ course }: CourseOverviewProps) { + const imageUrl = course?.thumbnailUrl ?? CardPlaceholder; + const title = course?.title; + const description = course?.description || ""; + + return ( + + +
+ {title} { + (e.target as HTMLImageElement).src = CardPlaceholder; + }} + /> +
+
+ +
{title}
+ +
+
+
+ ); +} diff --git a/apps/web/app/modules/Courses/NewCourseView/CourseProgressChart.tsx b/apps/web/app/modules/Courses/NewCourseView/CourseProgressChart.tsx new file mode 100644 index 00000000..2df87351 --- /dev/null +++ b/apps/web/app/modules/Courses/NewCourseView/CourseProgressChart.tsx @@ -0,0 +1,94 @@ +import { useMemo } from "react"; +import { Label, Pie, PieChart } from "recharts"; + +import { ChartContainer, ChartTooltip, ChartTooltipContent } from "~/components/ui/chart"; + +import type { ChartConfig } from "~/components/ui/chart"; + +type CourseProgressChartProps = { + chaptersCount: number | undefined; + completedChaptersCount: number | undefined; +}; + +const emptyChartData = { + state: "No data", + chaptersCount: 1, + fill: "var(--neutral-200)", +}; + +const chartConfig = { + completed: { + label: "Completed Chapters", + color: "var(--success-500)", + }, + notCompleted: { + label: "Remaining chapters", + color: "var(--primary-100)", + }, +} satisfies ChartConfig; + +export const CourseProgressChart = ({ + chaptersCount = 0, + completedChaptersCount = 0, +}: CourseProgressChartProps) => { + const chartData = useMemo( + () => [ + { + state: "Completed Chapters", + chaptersCount: completedChaptersCount, + fill: "var(--success-500)", + }, + { + state: "Remaining chapters", + chaptersCount: chaptersCount - completedChaptersCount, + fill: "var(--primary-100)", + }, + ], + [completedChaptersCount, chaptersCount], + ); + + const isEmptyChart = chartData.every(({ chaptersCount }) => !chaptersCount); + + return ( +
+ + + {!isEmptyChart && ( + } /> + )} + + + + +
+ ); +}; diff --git a/apps/web/app/modules/Courses/NewCourseView/CourseViewSidebar/CourseOptions.tsx b/apps/web/app/modules/Courses/NewCourseView/CourseViewSidebar/CourseOptions.tsx new file mode 100644 index 00000000..1cd9d9cc --- /dev/null +++ b/apps/web/app/modules/Courses/NewCourseView/CourseViewSidebar/CourseOptions.tsx @@ -0,0 +1,62 @@ +import { useEnrollCourse } from "~/api/mutations"; +import { courseQueryOptions } from "~/api/queries"; +import { queryClient } from "~/api/queryClient"; +import { CopyUrlButton } from "~/components/CopyUrlButton/CopyUrlButton"; +import { Icon } from "~/components/Icon"; +import { Button } from "~/components/ui/button"; +import { PaymentModal } from "~/modules/stripe/PaymentModal"; + +import type { GetCourseResponse } from "~/api/generated-api"; + +type CourseOptionsProps = { + course: GetCourseResponse["data"]; +}; + +export const CourseOptions = ({ course }: CourseOptionsProps) => { + const { mutateAsync: enrollCourse } = useEnrollCourse(); + + const handleEnrollCourse = async () => { + await enrollCourse({ id: course?.id }).then(() => { + queryClient.invalidateQueries(courseQueryOptions(course?.id)); + }); + }; + + return ( + <> +

Options

+
+ + + Share this course + + {course.priceInCents && course.currency ? ( + + ) : ( + + )} +
+ + ); +}; diff --git a/apps/web/app/modules/Courses/NewCourseView/CourseViewSidebar/CourseProgress.tsx b/apps/web/app/modules/Courses/NewCourseView/CourseViewSidebar/CourseProgress.tsx new file mode 100644 index 00000000..b6b10bb4 --- /dev/null +++ b/apps/web/app/modules/Courses/NewCourseView/CourseViewSidebar/CourseProgress.tsx @@ -0,0 +1,36 @@ +import { CopyUrlButton } from "~/components/CopyUrlButton"; +import { Icon } from "~/components/Icon"; +import { Button } from "~/components/ui/button"; +import { CourseProgressChart } from "~/modules/Courses/NewCourseView/CourseProgressChart"; + +import type { GetCourseResponse } from "~/api/generated-api"; + +type CourseProgressProps = { + course: GetCourseResponse["data"]; +}; + +export const CourseProgress = ({ course }: CourseProgressProps) => { + return ( + <> +

Course progress

+ +
+ + + Share this course + + +

+ + Quickly pick up where you left off with your most recent lesson. +

+
+ + ); +}; diff --git a/apps/web/app/modules/Courses/NewCourseView/CourseViewSidebar/CourseViewSidebar.tsx b/apps/web/app/modules/Courses/NewCourseView/CourseViewSidebar/CourseViewSidebar.tsx new file mode 100644 index 00000000..fa835c0b --- /dev/null +++ b/apps/web/app/modules/Courses/NewCourseView/CourseViewSidebar/CourseViewSidebar.tsx @@ -0,0 +1,64 @@ +import { Link } from "@remix-run/react"; + +import { useUserDetails } from "~/api/queries/useUserDetails"; +import { Gravatar } from "~/components/Gravatar"; +import { Avatar } from "~/components/ui/avatar"; +import { Button } from "~/components/ui/button"; +import { useUserRole } from "~/hooks/useUserRole"; +import { CourseOptions } from "~/modules/Courses/NewCourseView/CourseViewSidebar/CourseOptions"; +import { CourseProgress } from "~/modules/Courses/NewCourseView/CourseViewSidebar/CourseProgress"; + +import type { GetCourseResponse } from "~/api/generated-api"; + +type CourseViewSidebar = { + course: GetCourseResponse["data"]; +}; + +export const CourseViewSidebar = ({ course }: CourseViewSidebar) => { + const { data: userDetails } = useUserDetails(course?.authorId ?? ""); + const { isAdmin, isTeacher } = useUserRole(); + + const shouldShowCourseOptions = !isAdmin && !isTeacher && !course?.enrolled; + + return ( +
+ {shouldShowCourseOptions ? ( + + ) : ( + + )} +

Author

+
+ + + +
+

+ {userDetails?.firstName} {userDetails?.lastName} +

+
+

+ Title:{" "} + {userDetails?.jobTitle} +

+
+
+
+
+
+ About +
+
+

{userDetails?.description}

+
+ + +
+ ); +}; diff --git a/apps/web/app/modules/Courses/NewCourseView/MoreCoursesByAuthor.tsx b/apps/web/app/modules/Courses/NewCourseView/MoreCoursesByAuthor.tsx new file mode 100644 index 00000000..c2da4908 --- /dev/null +++ b/apps/web/app/modules/Courses/NewCourseView/MoreCoursesByAuthor.tsx @@ -0,0 +1,62 @@ +import { isEmpty } from "lodash-es"; + +import { useTeacherCourses } from "~/api/queries/useTeacherCourses"; +import { useUserDetails } from "~/api/queries/useUserDetails"; +import { Icon } from "~/components/Icon"; +import Loader from "~/modules/common/Loader/Loader"; +import { CoursesCarousel } from "~/modules/Dashboard/Courses/CoursesCarousel"; + +type MoreCoursesByAuthorProps = { + courseId: string; + teacherId: string | undefined; +}; + +export const MoreCoursesByAuthor = ({ courseId, teacherId }: MoreCoursesByAuthorProps) => { + const { data: teacherCourses, isLoading } = useTeacherCourses(teacherId, { + scope: "available", + excludeCourseId: courseId, + }); + const { data: teacherData } = useUserDetails(teacherId); + + if (!teacherCourses?.length) return null; + + return ( +
+
+

+ More courses by {teacherData?.firstName} {teacherData?.lastName} +

+

+ Below you can see more courses created by the same author +

+
+
+ {!teacherCourses || + (isEmpty(teacherCourses) && ( +
+
+ +
+
+

+ We could not find any courses +

+

+ Please change the search criteria or try again later +

+
+
+ ))} + {isLoading && ( +
+ +
+ )} + +
+
+ ); +}; diff --git a/apps/web/app/modules/Courses/NewCourseView/NewCourseView.page.tsx b/apps/web/app/modules/Courses/NewCourseView/NewCourseView.page.tsx new file mode 100644 index 00000000..f23f0c84 --- /dev/null +++ b/apps/web/app/modules/Courses/NewCourseView/NewCourseView.page.tsx @@ -0,0 +1,54 @@ +import { useParams } from "@remix-run/react"; + +import { useCourse } from "~/api/queries"; +import { PageWrapper } from "~/components/PageWrapper"; +import { CourseChapter } from "~/modules/Courses/NewCourseView/CourseChapter"; +import CourseOverview from "~/modules/Courses/NewCourseView/CourseOverview"; +import { CourseViewSidebar } from "~/modules/Courses/NewCourseView/CourseViewSidebar/CourseViewSidebar"; +import { MoreCoursesByAuthor } from "~/modules/Courses/NewCourseView/MoreCoursesByAuthor"; +import { YouMayBeInterestedIn } from "~/modules/Courses/NewCourseView/YouMayBeInterestedIn"; + +export default function NewCourseViewPage() { + const { id = "" } = useParams(); + const { data: course } = useCourse(id); + + if (!course) return null; + + // TODO: Add breadcrumbs + // const breadcrumbs = [ + // { + // title: "Dashboard", + // href: "/", + // }, + // { + // title: course?.title ?? "", + // href: `/course/${id}`, + // }, + // ]; + + return ( + +
+
+ +
+
+

Chapters

+

+ Below you can see all chapters inside this course +

+
+ {course?.chapters?.map((chapter) => { + if (!chapter) return null; + + return ; + })} +
+ + +
+ +
+
+ ); +} diff --git a/apps/web/app/modules/Courses/NewCourseView/YouMayBeInterestedIn.tsx b/apps/web/app/modules/Courses/NewCourseView/YouMayBeInterestedIn.tsx new file mode 100644 index 00000000..08e182f3 --- /dev/null +++ b/apps/web/app/modules/Courses/NewCourseView/YouMayBeInterestedIn.tsx @@ -0,0 +1,60 @@ +import { isEmpty } from "lodash-es"; + +import { useAvailableCourses } from "~/api/queries"; +import { Icon } from "~/components/Icon"; +import Loader from "~/modules/common/Loader/Loader"; +import { CoursesCarousel } from "~/modules/Dashboard/Courses/CoursesCarousel"; + +type YouMayBeInterestedInProps = { + category: string; + courseId: string; +}; + +export const YouMayBeInterestedIn = ({ category, courseId }: YouMayBeInterestedInProps) => { + const { data: relatedCourses, isLoading } = useAvailableCourses({ + category, + excludeCourseId: courseId, + }); + + if (!relatedCourses?.length) return null; + + return ( +
+
+

+ You may be interested in +

+

+ Below you can see courses with similar topic or knowledge domain{" "} +

+
+
+ {!relatedCourses || + (isEmpty(relatedCourses) && ( +
+
+ +
+
+

+ We could not find any courses +

+

+ Please change the search criteria or try again later +

+
+
+ ))} + {isLoading && ( +
+ +
+ )} + +
+
+ ); +}; diff --git a/apps/web/app/modules/Courses/NewCourseView/index.ts b/apps/web/app/modules/Courses/NewCourseView/index.ts new file mode 100644 index 00000000..67e02075 --- /dev/null +++ b/apps/web/app/modules/Courses/NewCourseView/index.ts @@ -0,0 +1 @@ +export { NewCourseViewPage } from "./NewCourseView.page"; diff --git a/apps/web/app/modules/Courses/NewCourseView/lessonTypes.ts b/apps/web/app/modules/Courses/NewCourseView/lessonTypes.ts new file mode 100644 index 00000000..c1665c85 --- /dev/null +++ b/apps/web/app/modules/Courses/NewCourseView/lessonTypes.ts @@ -0,0 +1,13 @@ +export const LessonTypes = { + presentation: "Presentation", + text_block: "Text Block", + video: "Video", + quiz: "Quiz", +} as const; + +export const LessonTypesIcons = { + presentation: "Presentation", + text_block: "Text", + video: "Video", + quiz: "Quiz", +} as const; diff --git a/apps/web/app/modules/Dashboard/Courses/CourseCard.tsx b/apps/web/app/modules/Dashboard/Courses/CourseCard.tsx index 45401786..652995b0 100644 --- a/apps/web/app/modules/Dashboard/Courses/CourseCard.tsx +++ b/apps/web/app/modules/Dashboard/Courses/CourseCard.tsx @@ -13,9 +13,9 @@ import CourseCardButton from "~/modules/Dashboard/Courses/CourseCardButton"; import { CourseCardTitle } from "./CourseCardTitle"; -import type { GetAllCoursesResponse } from "~/api/generated-api"; +import type { GetAvailableCoursesResponse } from "~/api/generated-api"; -export type CourseCardProps = GetAllCoursesResponse["data"][number]; +export type CourseCardProps = GetAvailableCoursesResponse["data"][number]; const CourseCard = ({ author, diff --git a/apps/web/app/modules/Dashboard/Courses/StudentCoursesCarousel.tsx b/apps/web/app/modules/Dashboard/Courses/CoursesCarousel.tsx similarity index 55% rename from apps/web/app/modules/Dashboard/Courses/StudentCoursesCarousel.tsx rename to apps/web/app/modules/Dashboard/Courses/CoursesCarousel.tsx index cf95670b..ee4d64f8 100644 --- a/apps/web/app/modules/Dashboard/Courses/StudentCoursesCarousel.tsx +++ b/apps/web/app/modules/Dashboard/Courses/CoursesCarousel.tsx @@ -9,27 +9,25 @@ import CourseCard from "~/modules/Dashboard/Courses/CourseCard"; import type { GetAllCoursesResponse } from "~/api/generated-api"; -type StudentCoursesCarouselProps = { - studentCourses?: GetAllCoursesResponse["data"]; +type CoursesCarouselProps = { + courses?: GetAllCoursesResponse["data"]; }; -export const StudentCoursesCarousel = ({ studentCourses }: StudentCoursesCarouselProps) => { +export const CoursesCarousel = ({ courses }: CoursesCarouselProps) => { const renderCarouselItems = () => { - if (!studentCourses) return null; + if (!courses?.length) return null; - return studentCourses.map((studentCourse) => { - if (studentCourse.enrolled) { - return ( - - - - ); - } + return courses.map((course) => { + if (!course) return null; - return null; + return ( + + + + ); }); }; @@ -37,7 +35,7 @@ export const StudentCoursesCarousel = ({ studentCourses }: StudentCoursesCarouse return ( - + {carouselItems}
diff --git a/apps/web/app/modules/Dashboard/Dashboard.page.tsx b/apps/web/app/modules/Dashboard/Dashboard.page.tsx index 4a2538b6..7030823d 100644 --- a/apps/web/app/modules/Dashboard/Dashboard.page.tsx +++ b/apps/web/app/modules/Dashboard/Dashboard.page.tsx @@ -29,7 +29,7 @@ import { import { DashboardIcon, HamburgerIcon } from "../icons/icons"; import { CourseList } from "./Courses/CourseList"; -import { StudentCoursesCarousel } from "./Courses/StudentCoursesCarousel"; +import { CoursesCarousel } from "./Courses/CoursesCarousel"; import type { MetaFunction } from "@remix-run/node"; @@ -191,7 +191,7 @@ export default function DashboardPage() {
)} - +
diff --git a/apps/web/app/modules/Statistics/Client/components/ContinueLearningCard.tsx b/apps/web/app/modules/Statistics/Client/components/ContinueLearningCard.tsx index 4774f994..9ae54f63 100644 --- a/apps/web/app/modules/Statistics/Client/components/ContinueLearningCard.tsx +++ b/apps/web/app/modules/Statistics/Client/components/ContinueLearningCard.tsx @@ -4,7 +4,7 @@ import { Icon } from "~/components/Icon"; import { Button } from "~/components/ui/button"; import { Skeleton } from "~/components/ui/skeleton"; import { useUserRole } from "~/hooks/useUserRole"; -import { LessonCard } from "~/modules/Courses/CourseView/LessonCard"; +import { ChapterCard } from "~/modules/Courses/CourseView/ChapterCard"; import type { GetUserStatisticsResponse } from "~/api/generated-api"; @@ -53,7 +53,7 @@ export const ContinueLearningCard = ({ isLoading = false, lesson }: ContinueLear {lesson?.courseDescription}

- - +
- +

- {user?.firstName} {user?.lastName} + {userDetails?.firstName} {userDetails?.lastName}

diff --git a/apps/web/app/modules/stripe/PaymentModal.tsx b/apps/web/app/modules/stripe/PaymentModal.tsx index 0bd3e7ff..047c69fc 100644 --- a/apps/web/app/modules/stripe/PaymentModal.tsx +++ b/apps/web/app/modules/stripe/PaymentModal.tsx @@ -1,19 +1,12 @@ import { Elements } from "@stripe/react-stripe-js"; -import { useState } from "react"; import { useStripePaymentIntent } from "~/api/mutations/useStripePaymentIntent"; -import { currentUserQueryOptions, useCurrentUserSuspense } from "~/api/queries/useCurrentUser"; +import { currentUserQueryOptions } from "~/api/queries/useCurrentUser"; import { queryClient } from "~/api/queryClient"; import { Button } from "~/components/ui/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "~/components/ui/dialog"; import { toast } from "~/components/ui/use-toast"; import { formatPrice } from "~/lib/formatters/priceFormatter"; +import { useCurrentUserStore } from "~/modules/common/store/useCurrentUserStore"; import { useStripePromise } from "./hooks/useStripePromise"; import { PaymentForm } from "./PaymentForm"; @@ -44,10 +37,7 @@ export function PaymentModal({ courseTitle, courseId, }: PaymentModalProps) { - const [open, setOpen] = useState(false); - const { - data: { id: currentUserId }, - } = useCurrentUserSuspense(); + const { currentUser } = useCurrentUserStore(); const stripePromise = useStripePromise(); const { clientSecret, createPaymentIntent, resetClientSecret } = useStripePaymentIntent(); @@ -56,17 +46,15 @@ export function PaymentModal({ await createPaymentIntent({ amount: coursePrice, currency: courseCurrency, - customerId: currentUserId, + customerId: currentUser?.id ?? "", courseId, }); - setOpen(true); } catch (error) { console.error("Error creating payment intent:", error); } }; - const handlePaymentSuccess = () => { - setOpen(false); + const handlePaymentSuccess = async () => { resetClientSecret(); toast({ description: "Payment successful", @@ -74,30 +62,35 @@ export function PaymentModal({ }; return ( -

- - - - - - Enroll in {courseTitle} - - {clientSecret && ( - - - - )} - - + + + Enroll to the course - {formatPrice(coursePrice, courseCurrency)} + + {clientSecret && ( + + + + )} + ); } diff --git a/apps/web/routes.ts b/apps/web/routes.ts index 669bef1b..3e1a26f7 100644 --- a/apps/web/routes.ts +++ b/apps/web/routes.ts @@ -15,7 +15,7 @@ export const routes: ( index: true, }); route("courses", "modules/Dashboard/Dashboard.page.tsx"); - route("course/:id", "modules/Courses/CourseView/CourseView.page.tsx"); + route("course/:id", "modules/Courses/NewCourseView/NewCourseView.page.tsx"); route("course/:courseId/lesson/:lessonId", "modules/Courses/Lesson/Lesson.page.tsx"); route("settings", "modules/Dashboard/Settings/Settings.page.tsx"); route("teachers/:id", "modules/Teacher/Teacher.page.tsx");