Skip to content

Commit

Permalink
feat: refactor enroll to course (#364)
Browse files Browse the repository at this point in the history
* feat: refactor enroll to course

* feat: repair completed chapters count

* feat: apply feedback, refactor condition for course statistic
  • Loading branch information
wielopolski authored Jan 13, 2025
1 parent 2a81ae8 commit e1ec228
Show file tree
Hide file tree
Showing 6 changed files with 128 additions and 121 deletions.
7 changes: 6 additions & 1 deletion apps/api/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@ module.exports = {
},
overrides: [
{
files: ["src/seed/**/*.ts", "src/stripe/stripe.controller.ts", "test/jest-setup.ts"],
files: [
"src/seed/**/*.ts",
"src/stripe/stripe.controller.ts",
"src/stripe/stripeWebhook.handler.ts",
"test/jest-setup.ts",
],
rules: {
"no-console": "off",
},
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/courses/course.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import { Module } from "@nestjs/common";

import { ChapterModule } from "src/chapter/chapter.module";
import { FileModule } from "src/file/files.module";
import { LessonModule } from "src/lesson/lesson.module";
import { StatisticsModule } from "src/statistics/statistics.module";

import { CourseController } from "./course.controller";
import { CourseService } from "./course.service";

@Module({
imports: [FileModule, StatisticsModule, ChapterModule],
imports: [FileModule, StatisticsModule, ChapterModule, LessonModule],
controllers: [CourseController],
providers: [CourseService],
exports: [],
exports: [CourseService],
})
export class CourseModule {}
120 changes: 79 additions & 41 deletions apps/api/src/courses/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ import {
ne,
sql,
} from "drizzle-orm";
import { isEmpty } from "lodash";

import { AdminChapterRepository } from "src/chapter/repositories/adminChapter.repository";
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 { LessonRepository } from "src/lesson/repositories/lesson.repository";
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";
Expand Down Expand Up @@ -69,6 +71,7 @@ export class CourseService {
@Inject("DB") private readonly db: DatabasePg,
private readonly adminChapterRepository: AdminChapterRepository,
private readonly fileService: FileService,
private readonly lessonRepository: LessonRepository,
private readonly statisticsRepository: StatisticsRepository,
) {}

Expand Down Expand Up @@ -777,56 +780,75 @@ export class CourseService {
const isTest = testKey && testKey === process.env.TEST_KEY;
if (!isTest && Boolean(course.price)) throw new ForbiddenException();

await this.db.transaction(async (trx) => {
const [enrolledCourse] = await trx
.insert(studentCourses)
.values({ studentId: studentId, courseId: id })
.returning();
await this.createCourseDependencies(id, studentId);
}

if (!enrolledCourse) throw new ConflictException("Course not enrolled");
async createCourseDependencies(
courseId: UUIDType,
studentId: UUIDType,
paymentId: string | null = null,
) {
const [enrolledCourse] = await this.db
.insert(studentCourses)
.values({ studentId, courseId, paymentId })
.returning();

if (!enrolledCourse) throw new ConflictException("Course not enrolled");

await this.db.transaction(async (trx) => {
const courseChapterList = await trx
.select({
id: chapters.id,
itemCount: chapters.lessonCount,
})
.from(chapters)
.leftJoin(lessons, eq(lessons.chapterId, chapters.id))
.where(and(eq(chapters.courseId, course.id), eq(chapters.isPublished, true)))
.where(and(eq(chapters.courseId, courseId), eq(chapters.isPublished, true)))
.groupBy(chapters.id);

const existingLessonProgress = await this.lessonRepository.getLessonsProgressByCourseId(
courseId,
studentId,
trx,
);

await this.createStatisicRecordForCourse(
courseId,
paymentId,
isEmpty(existingLessonProgress),
trx,
);

if (courseChapterList.length > 0) {
await trx.insert(studentChapterProgress).values(
courseChapterList.map((chapter) => ({
studentId,
chapterId: chapter.id,
courseId: course.id,
courseId,
completedLessonItemCount: 0,
})),
);

courseChapterList.forEach(async (chapter) => {
const chapterLessons = await trx
.select({ id: lessons.id, type: lessons.type })
.from(lessons)
.where(eq(lessons.chapterId, chapter.id));

await trx.insert(studentLessonProgress).values(
chapterLessons.map((lesson) => ({
studentId,
lessonId: lesson.id,
chapterId: chapter.id,
completedQuestionCount: 0,
quizScore: lesson.type === LESSON_TYPES.QUIZ ? 0 : null,
completedAt: null,
})),
);
});
await Promise.all(
courseChapterList.map(async (chapter) => {
const chapterLessons = await trx
.select({ id: lessons.id, type: lessons.type })
.from(lessons)
.where(eq(lessons.chapterId, chapter.id));

await trx.insert(studentLessonProgress).values(
chapterLessons.map((lesson) => ({
studentId,
lessonId: lesson.id,
chapterId: chapter.id,
completedQuestionCount: 0,
quizScore: lesson.type === LESSON_TYPES.QUIZ ? 0 : null,
completedAt: null,
})),
);
}),
);
}

// TODO: add lesson progress records

await this.statisticsRepository.updateFreePurchasedCoursesStats(course.id, trx);
});
}

Expand Down Expand Up @@ -903,21 +925,38 @@ export class CourseService {
});
}

private async createStatisicRecordForCourse(
courseId: UUIDType,
paymentId: string | null,
existingFreemiumLessonProgress: boolean,
dbInstance: PostgresJsDatabase<typeof schema> = this.db,
) {
if (!paymentId) {
return this.statisticsRepository.updateFreePurchasedCoursesStats(courseId, dbInstance);
}

if (existingFreemiumLessonProgress) {
return this.statisticsRepository.updatePaidPurchasedCoursesStats(courseId, dbInstance);
}

return this.statisticsRepository.updatePaidPurchasedAfterFreemiumCoursesStats(
courseId,
dbInstance,
);
}

private async addS3SignedUrls(data: AllCoursesResponse): Promise<AllCoursesResponse> {
return Promise.all(
data.map(async (item) => {
if (item.thumbnailUrl) {
if (item.thumbnailUrl.startsWith("https://")) return item;

try {
const signedUrl = await this.fileService.getFileUrl(item.thumbnailUrl);
return { ...item, thumbnailUrl: signedUrl };
} catch (error) {
console.error(`Failed to get signed URL for ${item.thumbnailUrl}:`, error);
return item;
}
if (!item.thumbnailUrl) return item;

try {
const signedUrl = await this.fileService.getFileUrl(item.thumbnailUrl);
return { ...item, thumbnailUrl: signedUrl };
} catch (error) {
console.error(`Failed to get signed URL for ${item.thumbnailUrl}:`, error);
return item;
}
return item;
}),
);
}
Expand Down Expand Up @@ -1023,7 +1062,6 @@ export class CourseService {
return conditions ?? undefined;
}

// TODO: repair last 2 functions
private getColumnToSortBy(sort: CourseSortField) {
switch (sort) {
case CourseSortFields.author:
Expand Down
6 changes: 2 additions & 4 deletions apps/api/src/stripe/stripe.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { StripeModule as StripeModuleConfig, StripeWebhookService } from "@golev
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";

import { LessonModule } from "src/lesson/lesson.module";
import { StatisticsModule } from "src/statistics/statistics.module";
import { CourseModule } from "src/courses/course.module";

import { StripeController } from "./stripe.controller";
import { StripeService } from "./stripe.service";
Expand All @@ -28,8 +27,7 @@ import { StripeWebhookHandler } from "./stripeWebhook.handler";
};
},
}),
LessonModule,
StatisticsModule,
CourseModule,
],
controllers: [StripeController],
providers: [StripeService, StripeWebhookHandler, StripeWebhookService],
Expand Down
73 changes: 11 additions & 62 deletions apps/api/src/stripe/stripeWebhook.handler.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,17 @@
import { StripeWebhookHandler as StripeWebhookHandlerDecorator } from "@golevelup/nestjs-stripe";
import { Inject, Injectable } from "@nestjs/common";
import { and, eq } from "drizzle-orm";
import { isEmpty } from "lodash";
import { eq } from "drizzle-orm";
import Stripe from "stripe";

import { DatabasePg } from "src/common";
import { LessonRepository } from "src/lesson/repositories/lesson.repository";
import { StatisticsRepository } from "src/statistics/repositories/statistics.repository";
import {
chapters,
courses,
lessons,
studentChapterProgress,
studentCourses,
users,
} from "src/storage/schema";
import { CourseService } from "src/courses/course.service";
import { courses, users } from "src/storage/schema";

@Injectable()
export class StripeWebhookHandler {
constructor(
@Inject("DB") private readonly db: DatabasePg,
private readonly statisticsRepository: StatisticsRepository,
private readonly lessonRepository: LessonRepository,
readonly courseService: CourseService,
) {}

@StripeWebhookHandlerDecorator("payment_intent.succeeded")
Expand All @@ -40,53 +30,12 @@ export class StripeWebhookHandler {

if (!course) return null;

const [payment] = await this.db
.insert(studentCourses)
.values({
studentId: user.id,
courseId: course.id,
paymentId: paymentIntent.id,
})
.returning();

if (!payment) return null;

await this.db.transaction(async (trx) => {
const courseChapterList = await trx
.select({
id: chapters.id,
type: lessons.type,
itemCount: chapters.lessonCount,
})
.from(chapters)
.leftJoin(lessons, eq(lessons.chapterId, chapters.id))
.where(and(eq(chapters.courseId, course.id), eq(chapters.isPublished, true)))
.groupBy(chapters.id);

if (courseChapterList.length > 0) {
await trx.insert(studentChapterProgress).values(
courseChapterList.map((chapter) => ({
studentId: userId,
chapterId: chapter.id,
courseId: course.id,
completedLessonItemCount: 0,
})),
);
}
const existingLessonProgress = await this.lessonRepository.getLessonsProgressByCourseId(
course.id,
userId,
trx,
);

return isEmpty(existingLessonProgress)
? await this.statisticsRepository.updatePaidPurchasedCoursesStats(course.id, trx)
: await this.statisticsRepository.updatePaidPurchasedAfterFreemiumCoursesStats(
course.id,
trx,
);
});

return true;
try {
await this.courseService.createCourseDependencies(courseId, userId, paymentIntent.id);
return true;
} catch (error) {
console.log(error);
return null;
}
}
}
Loading

0 comments on commit e1ec228

Please sign in to comment.