Skip to content

Commit

Permalink
feat: 361 allow to mark lessons in course as free (#232)
Browse files Browse the repository at this point in the history
* feat: add is free lesson to course

* feat: FreeRight icon

* feat: CardBadge component

* fix: Viever component list formatting

* feat: freemium lessons

* feat: freemium lessons

* fix: add missing values in seeds

* feat: add posibility to answers and diplays free lessons content

---------

Co-authored-by: Piotr Pająk <[email protected]>
  • Loading branch information
wielopolski and piotr-pajak authored Nov 15, 2024
1 parent fcf417e commit df3c6ea
Show file tree
Hide file tree
Showing 44 changed files with 3,448 additions and 328 deletions.
6 changes: 4 additions & 2 deletions apps/api/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module.exports = {
tsconfigRootDir: __dirname,
sourceType: "module",
},
plugins: ["@typescript-eslint", "import"],
plugins: ["@typescript-eslint", "import", "unused-imports"],
extends: ["plugin:@typescript-eslint/recommended", "plugin:import/typescript"],
root: true,
env: {
Expand All @@ -20,7 +20,7 @@ module.exports = {
},
},
rules: {
"import/no-duplicates": "error",
"import/no-duplicates": ["error", { considerQueryString: true }],
"import/order": [
"error",
{
Expand Down Expand Up @@ -49,5 +49,7 @@ module.exports = {
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
},
};
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-unused-imports": "^4.1.4",
"fishery": "^2.2.2",
"jest": "^29.5.0",
"prettier": "^3.0.0",
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/courses/courses.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,13 @@ export class CoursesService {
authorId: courses.authorId,
author: sql<string>`${users.firstName} || ' ' || ${users.lastName}`,
authorEmail: sql<string>`${users.email}`,
hasFreeLessons: sql<boolean>`
EXISTS (
SELECT 1
FROM ${courseLessons}
WHERE ${courseLessons.courseId} = ${courses.id}
AND ${courseLessons.isFree} = true
)`,
})
.from(courses)
.innerJoin(categories, eq(courses.categoryId, categories.id))
Expand All @@ -303,6 +310,7 @@ export class CoursesService {
studentCourses,
and(eq(courses.id, studentCourses.courseId), eq(studentCourses.studentId, userId)),
)
.leftJoin(courseLessons, eq(courses.id, courseLessons.courseId))
.where(and(eq(courses.id, id), eq(courses.archived, false)));

if (!course) throw new NotFoundException("Course not found");
Expand Down Expand Up @@ -360,6 +368,7 @@ export class CoursesService {
ELSE ${LessonProgress.notStarted}
END)
`,
isFree: courseLessons.isFree,
})
.from(courseLessons)
.innerJoin(lessons, eq(courseLessons.lessonId, lessons.id))
Expand Down Expand Up @@ -424,6 +433,7 @@ export class CoursesService {
(SELECT COUNT(*)
FROM ${lessonItems}
WHERE ${lessonItems.lessonId} = ${lessons.id} AND ${lessonItems.lessonItemType} != 'text_block')::INTEGER`,
isFree: courseLessons.isFree,
})
.from(courseLessons)
.innerJoin(lessons, eq(courseLessons.lessonId, lessons.id))
Expand Down Expand Up @@ -777,6 +787,13 @@ export class CoursesService {
)::INTEGER`,
priceInCents: courses.priceInCents,
currency: courses.currency,
hasFreeLessons: sql<boolean>`
EXISTS (
SELECT 1
FROM ${courseLessons}
WHERE ${courseLessons.courseId} = ${courses.id}
AND ${courseLessons.isFree} = true
)`,
};
}

Expand Down
1 change: 1 addition & 0 deletions apps/api/src/courses/schemas/course.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const courseSchema = Type.Object({
state: Type.Optional(Type.String()),
archived: Type.Optional(Type.Boolean()),
createdAt: Type.Optional(Type.String()),
hasFreeLessons: Type.Optional(Type.Boolean()),
});

export const allCoursesSchema = Type.Array(courseSchema);
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/courses/schemas/showCourseCommon.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const commonShowCourseSchema = Type.Object({
priceInCents: Type.Number(),
currency: Type.String(),
archived: Type.Optional(Type.Boolean()),
hasFreeLessons: Type.Optional(Type.Boolean()),
});

export type CommonShowCourse = Static<typeof commonShowCourseSchema>;
1 change: 1 addition & 0 deletions apps/api/src/e2e-data-seeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const e2eCourses: NiceCourseData[] = [
imageUrl: "https://placehold.co/600x400",
type: LESSON_TYPE.multimedia.key,
state: STATUS.published.key,
isFree: false,
items: [
{
itemType: LESSON_ITEM_TYPE.text_block.key,
Expand Down
9 changes: 7 additions & 2 deletions apps/api/src/lessons/adminLessons.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {

import type { CreateLessonBody, UpdateLessonBody } from "./schemas/lesson.schema";
import type { LessonItemResponse } from "./schemas/lessonItem.schema";
import type { UUIDType } from "src/common";

interface LessonsQuery {
filters?: LessonsFilterSchema;
Expand Down Expand Up @@ -136,8 +137,8 @@ export class AdminLessonsService {
};
}

async getAvailableLessons() {
const availableLessons = await this.adminLessonsRepository.getAvailableLessons();
async getAvailableLessons(courseId: UUIDType) {
const availableLessons = await this.adminLessonsRepository.getAvailableLessons(courseId);

if (isEmpty(availableLessons)) throw new NotFoundException("Lessons not found");

Expand Down Expand Up @@ -169,6 +170,10 @@ export class AdminLessonsService {
if (!lesson) throw new NotFoundException("Lesson not found");
}

async toggleLessonAsFree(courseId: UUIDType, lessonId: UUIDType, isFree: boolean) {
return await this.adminLessonsRepository.toggleLessonAsFree(courseId, lessonId, isFree);
}

async addLessonToCourse(courseId: string, lessonId: string, displayOrder?: number) {
try {
if (displayOrder === undefined) {
Expand Down
82 changes: 49 additions & 33 deletions apps/api/src/lessons/api/lessons.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,31 +31,33 @@ import { LessonsService } from "../lessons.service";
import {
type AllLessonsResponse,
allLessonsSchema,
type CreateLessonBody,
createLessonSchema,
type ShowLessonResponse,
lessonWithCountItems,
showLessonSchema,
type UpdateLessonBody,
updateLessonSchema,
type CreateLessonBody,
type ShowLessonResponse,
type UpdateLessonBody,
type LessonWithCountItems,
} from "../schemas/lesson.schema";
import {
type FileInsertType,
fileUpdateSchema,
type GetAllLessonItemsResponse,
GetAllLessonItemsResponseSchema,
type GetSingleLessonItemsResponse,
GetSingleLessonItemsResponseSchema,
type QuestionInsertType,
questionUpdateSchema,
type TextBlockInsertType,
textBlockUpdateSchema,
type FileInsertType,
type GetAllLessonItemsResponse,
type GetSingleLessonItemsResponse,
type QuestionInsertType,
type TextBlockInsertType,
type UpdateFileBody,
type UpdateQuestionBody,
type UpdateTextBlockBody,
} from "../schemas/lessonItem.schema";
import {
type LessonsFilterSchema,
sortLessonFieldsOptions,
type LessonsFilterSchema,
type SortLessonFieldsOptions,
} from "../schemas/lessonQuery";

Expand Down Expand Up @@ -103,30 +105,13 @@ export class LessonsController {
@Get("available-lessons")
@Roles(USER_ROLES.tutor, USER_ROLES.admin)
@Validate({
response: baseResponse(
Type.Array(
Type.Object({
id: Type.String(),
title: Type.String(),
description: Type.String(),
imageUrl: Type.String(),
itemsCount: Type.Number(),
}),
),
),
request: [{ type: "query", name: "courseId", schema: UUIDSchema, required: true }],
response: baseResponse(Type.Array(lessonWithCountItems)),
})
async getAvailableLessons(): Promise<
BaseResponse<
Array<{
id: string;
title: string;
description: string;
imageUrl: string;
itemsCount: number;
}>
>
> {
const availableLessons = await this.adminLessonsService.getAvailableLessons();
async getAvailableLessons(
@Query("courseId") courseId: UUIDType,
): Promise<BaseResponse<Array<LessonWithCountItems>>> {
const availableLessons = await this.adminLessonsService.getAvailableLessons(courseId);
return new BaseResponse(availableLessons);
}

Expand All @@ -151,7 +136,7 @@ export class LessonsController {
}

@Get("lesson/:id")
@Roles(...Object.values(USER_ROLES))
@Roles(USER_ROLES.tutor, USER_ROLES.admin)
@Validate({
response: baseResponse(showLessonSchema),
})
Expand Down Expand Up @@ -248,6 +233,37 @@ export class LessonsController {
});
}

@Patch("course-lesson")
@Roles(USER_ROLES.tutor, USER_ROLES.admin)
@Validate({
request: [
{
type: "body",
schema: Type.Object({
courseId: UUIDSchema,
lessonId: UUIDSchema,
isFree: Type.Boolean(),
}),
},
],
response: baseResponse(Type.Object({ isFree: Type.Boolean(), message: Type.String() })),
})
async toggleLessonAsFree(
@Body() body: { courseId: string; lessonId: string; isFree: boolean },
): Promise<BaseResponse<{ isFree: boolean; message: string }>> {
const [toggledLesson] = await this.adminLessonsService.toggleLessonAsFree(
body.courseId,
body.lessonId,
body.isFree,
);
return new BaseResponse({
isFree: toggledLesson.isFree,
message: body.isFree
? "Lesson toggled as free successfully"
: "Lesson toggled as not free successfully",
});
}

@Post("evaluation-quiz")
@Roles(USER_ROLES.student)
@Validate({
Expand Down
29 changes: 17 additions & 12 deletions apps/api/src/lessons/lessons.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,11 @@ export class LessonsService {
id,
userId,
);
const lesson = await this.lessonsRepository.getLessonForUser(courseId, id, userId);

if (!isAdmin && !accessCourseLessons)
if (!isAdmin && !accessCourseLessons && !lesson.isFree)
throw new UnauthorizedException("You don't have access to this lesson");

const lesson = await this.lessonsRepository.getLessonForUser(courseId, id, userId);

if (!lesson) throw new NotFoundException("Lesson not found");

const getImageUrl = async (url: string) => {
Expand Down Expand Up @@ -82,24 +81,30 @@ export class LessonsService {

const lessonProgress = await this.lessonsRepository.lessonProgress(courseId, lesson.id, userId);

if (!lessonProgress) throw new NotFoundException("Lesson progress not found");
if (!lessonProgress && !isAdmin && !lesson.isFree)
throw new NotFoundException("Lesson progress not found");

const isAdminOrFreeLessonWithoutLessonProgress = (isAdmin || lesson.isFree) && !lessonProgress;

const questionLessonItems = await this.getLessonQuestions(
lesson,
courseId,
userId,
lessonProgress.quizCompleted,
isAdminOrFreeLessonWithoutLessonProgress ? false : lessonProgress.quizCompleted,
);

return {
...lesson,
imageUrl,
lessonItems: questionLessonItems,
itemsCount: lessonProgress.lessonItemCount,
itemsCompletedCount: lessonProgress.completedLessonItemCount,
quizScore: lessonProgress.quizScore,
lessonProgress:
lessonProgress.completedLessonItemCount === 0
itemsCount: isAdminOrFreeLessonWithoutLessonProgress ? 0 : lessonProgress.lessonItemCount,
itemsCompletedCount: isAdminOrFreeLessonWithoutLessonProgress
? 0
: lessonProgress.completedLessonItemCount,
quizScore: isAdminOrFreeLessonWithoutLessonProgress ? 0 : lessonProgress.quizScore,
lessonProgress: isAdminOrFreeLessonWithoutLessonProgress
? LessonProgress.notStarted
: lessonProgress.completedLessonItemCount === 0
? LessonProgress.notStarted
: lessonProgress.completedLessonItemCount > 0
? LessonProgress.inProgress
Expand All @@ -114,12 +119,12 @@ export class LessonsService {
userId,
);

if (!accessCourseLessons)
if (!accessCourseLessons.isAssigned && !accessCourseLessons.isFree)
throw new UnauthorizedException("You don't have assignment to this lesson");

const quizProgress = await this.lessonsRepository.getQuizProgress(courseId, lessonId, userId);

if (quizProgress.quizCompleted) throw new ConflictException("Quiz already completed");
if (quizProgress?.quizCompleted) throw new ConflictException("Quiz already completed");

const lessonItemsCount = await this.lessonsRepository.getLessonItemCount(lessonId);

Expand Down
15 changes: 14 additions & 1 deletion apps/api/src/lessons/repositories/adminLessons.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export class AdminLessonsRepository {
return lesson;
}

async getAvailableLessons() {
async getAvailableLessons(courseId: UUIDType) {
return await this.db
.select({
id: lessons.id,
Expand All @@ -65,8 +65,13 @@ export class AdminLessonsRepository {
(SELECT COUNT(*)
FROM ${lessonItems}
WHERE ${lessonItems.lessonId} = ${lessons.id} AND ${lessonItems.lessonItemType} != 'text_block')::INTEGER`,
isFree: sql<boolean>`COALESCE(${courseLessons.isFree}, false)`,
})
.from(lessons)
.leftJoin(
courseLessons,
and(eq(courseLessons.lessonId, lessons.id), eq(courseLessons.courseId, courseId)),
)
.where(
and(
eq(lessons.archived, false),
Expand Down Expand Up @@ -167,6 +172,14 @@ export class AdminLessonsRepository {
});
}

async toggleLessonAsFree(courseId: UUIDType, lessonId: UUIDType, isFree: boolean) {
return await this.db
.update(courseLessons)
.set({ isFree })
.where(and(eq(courseLessons.lessonId, lessonId), eq(courseLessons.courseId, courseId)))
.returning();
}

async createLesson(body: CreateLessonBody, authorId: string) {
return await this.db
.insert(lessons)
Expand Down
Loading

0 comments on commit df3c6ea

Please sign in to comment.