diff --git a/.github/workflows/deploy-api-staging.yml b/.github/workflows/deploy-api-staging.yml index 725f0ab7..d63e598e 100644 --- a/.github/workflows/deploy-api-staging.yml +++ b/.github/workflows/deploy-api-staging.yml @@ -56,7 +56,7 @@ jobs: ECS_CLUSTER: ${{ vars.AWS_ECS_CLUSTER }} ECS_SERVICE: ${{ vars.AWS_ECS_SERVICE }} DOCKER_IMAGE: ${{ secrets.AWS_ECR_REGISTRY }}:${{ github.sha }} - run: selleo aws ecs deploy --region $AWS_REGION --cluster $ECS_CLUSTER --service $ECS_SERVICE --docker-image $DOCKER_IMAGE --one-off migrate --one-off seed + run: selleo aws ecs deploy --region $AWS_REGION --cluster $ECS_CLUSTER --service $ECS_SERVICE --docker-image $DOCKER_IMAGE --one-off migrate --one-off seed-staging - name: ECS Run migrations env: @@ -70,4 +70,4 @@ jobs: AWS_REGION: ${{ vars.AWS_REGION }} ECS_CLUSTER: ${{ vars.AWS_ECS_CLUSTER }} ECS_SERVICE: ${{ vars.AWS_ECS_SERVICE }} - run: selleo aws ecs run --region $AWS_REGION --cluster $ECS_CLUSTER --service $ECS_SERVICE --one-off seed + run: selleo aws ecs run --region $AWS_REGION --cluster $ECS_CLUSTER --service $ECS_SERVICE --one-off seed-staging diff --git a/apps/api/entrypoint.sh b/apps/api/entrypoint.sh index 1a7f9216..99173abc 100644 --- a/apps/api/entrypoint.sh +++ b/apps/api/entrypoint.sh @@ -9,13 +9,16 @@ if [ $COMMAND = "server" ]; then elif [ $COMMAND = "migrate" ]; then echo "Running migrations..." npm run db:migrate -elif [ $COMMAND = "seed" ]; then +elif [ $COMMAND = "seed-staging" ]; then + echo "Running seeds..." + npm run db:seed-staging +elif [ $COMMAND = "seed-prod" ]; then echo "Running seeds..." npm run db:seed-prod elif [ $COMMAND = "truncate-tables" ]; then echo "Truncating tables..." npm run db:truncate-tables else - echo "Usage: entrypoint.sh [server|migrate|seed|truncate-tables]" + echo "Usage: entrypoint.sh [server|migrate|seed-staging|seed-prod|truncate-tables]" exit 1 fi diff --git a/apps/api/package.json b/apps/api/package.json index 43ba99cd..c9ff3900 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -25,8 +25,9 @@ "test:e2e:watch": "jest --config ./test/jest-e2e.json --watch", "db:migrate": "drizzle-kit migrate", "db:generate": "drizzle-kit generate", - "db:seed": "ts-node -r tsconfig-paths/register ./src/seed.ts", - "db:seed-prod": "node dist/src/seed.js", + "db:seed": "ts-node -r tsconfig-paths/register ./src/seed/seed.ts", + "db:seed-staging": "node dist/src/seed/seed-staging.js", + "db:seed-prod": "node dist/src/seed/seed.js", "db:truncate-tables": "node dist/src/truncateTables.js" }, "dependencies": { diff --git a/apps/api/src/courses/courses.controller.ts b/apps/api/src/courses/courses.controller.ts index 26f8ee00..8d534321 100644 --- a/apps/api/src/courses/courses.controller.ts +++ b/apps/api/src/courses/courses.controller.ts @@ -144,7 +144,7 @@ export class CoursesController { }; const query = { filters, page, perPage, sort }; - const data = await this.coursesService.getAvailableCourses(query); + const data = await this.coursesService.getAvailableCourses(query, currentUserId); return new PaginatedResponse(data); } diff --git a/apps/api/src/courses/courses.service.ts b/apps/api/src/courses/courses.service.ts index 91e2fd76..03ebfe61 100644 --- a/apps/api/src/courses/courses.service.ts +++ b/apps/api/src/courses/courses.service.ts @@ -14,7 +14,6 @@ import { ilike, inArray, isNotNull, - isNull, like, sql, } from "drizzle-orm"; @@ -85,14 +84,14 @@ export class CoursesService { const { sortOrder, sortedField } = getSortOptions(sort); - return await this.db.transaction(async (tx) => { + return await this.db.transaction(async (trx) => { const conditions = this.getFiltersConditions(filters, false); if (currentUserRole === USER_ROLES.teacher && currentUserId) { conditions.push(eq(courses.authorId, currentUserId)); } - const queryDB = tx + const queryDB = trx .select({ id: courses.id, description: sql`${courses.description}`, @@ -163,11 +162,11 @@ export class CoursesService { const { sortOrder, sortedField } = getSortOptions(sort); - return this.db.transaction(async (tx) => { + return this.db.transaction(async (trx) => { const conditions = [eq(studentCourses.studentId, userId)]; conditions.push(...this.getFiltersConditions(filters)); - const queryDB = tx + const queryDB = trx .select(this.getSelectField()) .from(studentCourses) .innerJoin(courses, eq(studentCourses.courseId, courses.id)) @@ -196,7 +195,7 @@ export class CoursesService { const dynamicQuery = queryDB.$dynamic(); const paginatedQuery = addPagination(dynamicQuery, page, perPage); const data = await paginatedQuery; - const [{ totalItems }] = await tx + const [{ totalItems }] = await trx .select({ totalItems: countDistinct(courses.id) }) .from(studentCourses) .innerJoin(courses, eq(studentCourses.courseId, courses.id)) @@ -220,8 +219,10 @@ export class CoursesService { }); } + // TODO: remove unused select fields async getAvailableCourses( query: CoursesQuery, + currentUserId: UUIDType, ): Promise<{ data: AllCoursesResponse; pagination: Pagination }> { const { sort = CourseSortFields.title, @@ -231,18 +232,49 @@ export class CoursesService { } = query; const { sortOrder, sortedField } = getSortOptions(sort); - return this.db.transaction(async (tx) => { - const conditions = [ - eq(courses.state, STATES.published), - eq(courses.archived, false), - isNull(studentCourses.studentId), - ]; + return this.db.transaction(async (trx) => { + const notEnrolledCourses: Record[] = await trx.execute(sql` + SELECT id AS "courseId" + FROM courses + WHERE id NOT IN ( + SELECT DISTINCT course_id + FROM student_courses + WHERE student_id = ${currentUserId} + )`); + const notEnrolledCourseIds = notEnrolledCourses.map(({ courseId }) => courseId); + + const conditions = [eq(courses.state, STATES.published), eq(courses.archived, false)]; conditions.push(...this.getFiltersConditions(filters)); - const queryDB = tx - .select(this.getSelectField()) + if (notEnrolledCourses.length > 0) { + conditions.push(inArray(courses.id, notEnrolledCourseIds)); + } + + const queryDB = trx + .select({ + id: courses.id, + description: sql`${courses.description}`, + title: courses.title, + imageUrl: courses.imageUrl, + authorId: sql`${courses.authorId}`, + author: sql`CONCAT(${users.firstName} || ' ' || ${users.lastName})`, + authorEmail: sql`${users.email}`, + category: sql`${categories.title}`, + enrolled: sql`FALSE`, + enrolledParticipantCount: sql`COALESCE(${coursesSummaryStats.freePurchasedCount} + ${coursesSummaryStats.paidPurchasedCount}, 0)`, + courseLessonCount: courses.lessonsCount, + completedLessonCount: sql`0`, + priceInCents: courses.priceInCents, + currency: courses.currency, + hasFreeLessons: sql` + EXISTS ( + SELECT 1 + FROM ${courseLessons} + WHERE ${courseLessons.courseId} = ${courses.id} + AND ${courseLessons.isFree} = true + )`, + }) .from(courses) - .leftJoin(studentCourses, eq(studentCourses.courseId, courses.id)) .leftJoin(categories, eq(courses.categoryId, categories.id)) .leftJoin(users, eq(courses.authorId, users.id)) .leftJoin(courseLessons, eq(courses.id, courseLessons.courseId)) @@ -257,18 +289,16 @@ export class CoursesService { users.firstName, users.lastName, users.email, - studentCourses.studentId, categories.title, coursesSummaryStats.freePurchasedCount, coursesSummaryStats.paidPurchasedCount, - studentCourses.finishedLessonsCount, ) .orderBy(sortOrder(this.getColumnToSortBy(sortedField as CourseSortField))); const dynamicQuery = queryDB.$dynamic(); const paginatedQuery = addPagination(dynamicQuery, page, perPage); const data = await paginatedQuery; - const [{ totalItems }] = await tx + const [{ totalItems }] = await trx .select({ totalItems: countDistinct(courses.id) }) .from(courses) .leftJoin(studentCourses, eq(studentCourses.courseId, courses.id)) @@ -643,8 +673,8 @@ export class CoursesService { image?: Express.Multer.File, currentUserId?: UUIDType, ) { - return this.db.transaction(async (tx) => { - const [existingCourse] = await tx.select().from(courses).where(eq(courses.id, id)); + return this.db.transaction(async (trx) => { + const [existingCourse] = await trx.select().from(courses).where(eq(courses.id, id)); if (!existingCourse) { throw new NotFoundException("Course not found"); @@ -655,7 +685,7 @@ export class CoursesService { } if (updateCourseBody.categoryId) { - const [category] = await tx + const [category] = await trx .select() .from(categories) .where(eq(categories.id, updateCourseBody.categoryId)); @@ -681,7 +711,7 @@ export class CoursesService { ...(imageKey && { imageUrl: imageKey.fileUrl }), }; - const [updatedCourse] = await tx + const [updatedCourse] = await trx .update(courses) .set(updateData) .where(eq(courses.id, id)) diff --git a/apps/api/src/e2e-data-seeds.ts b/apps/api/src/seed/e2e-data-seeds.ts similarity index 93% rename from apps/api/src/e2e-data-seeds.ts rename to apps/api/src/seed/e2e-data-seeds.ts index 29d89de4..db5150f8 100644 --- a/apps/api/src/e2e-data-seeds.ts +++ b/apps/api/src/seed/e2e-data-seeds.ts @@ -1,7 +1,7 @@ -import { LESSON_ITEM_TYPE, LESSON_TYPE } from "./lessons/lesson.type"; -import { STATUS } from "./storage/schema/utils"; +import { LESSON_ITEM_TYPE, LESSON_TYPE } from "../lessons/lesson.type"; +import { STATUS } from "../storage/schema/utils"; -import type { NiceCourseData } from "./utils/types/test-types"; +import type { NiceCourseData } from "../utils/types/test-types"; export const e2eCourses: NiceCourseData[] = [ { diff --git a/apps/api/src/nice-data-seeds.ts b/apps/api/src/seed/nice-data-seeds.ts similarity index 52% rename from apps/api/src/nice-data-seeds.ts rename to apps/api/src/seed/nice-data-seeds.ts index 4b2beeb6..ebf62f68 100644 --- a/apps/api/src/nice-data-seeds.ts +++ b/apps/api/src/seed/nice-data-seeds.ts @@ -2,10 +2,10 @@ import { faker } from "@faker-js/faker"; import { LESSON_FILE_TYPE, LESSON_ITEM_TYPE, LESSON_TYPE } from "src/lessons/lesson.type"; -import { QUESTION_TYPE } from "./questions/schema/questions.types"; -import { STATUS } from "./storage/schema/utils"; +import { QUESTION_TYPE } from "../questions/schema/questions.types"; +import { STATUS } from "../storage/schema/utils"; -import type { NiceCourseData } from "./utils/types/test-types"; +import type { NiceCourseData } from "../utils/types/test-types"; export const niceCourses: NiceCourseData[] = [ { @@ -43,7 +43,6 @@ export const niceCourses: NiceCourseData[] = [ itemType: LESSON_ITEM_TYPE.file.key, title: "HTML Elements Video", type: LESSON_FILE_TYPE.external_video.key, - url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4", state: STATUS.published.key, body: "Learn the basics of web development with HTML! Master the structure and tags needed to build professional websites from scratch.", }, @@ -174,7 +173,6 @@ export const niceCourses: NiceCourseData[] = [ itemType: LESSON_ITEM_TYPE.file.key, title: "HTML Hyperlinks Presentation", type: LESSON_FILE_TYPE.external_presentation.key, - url: "https://res.cloudinary.com/dinpapxzv/raw/upload/v1727104719/presentation_gp0o3d.pptx", state: STATUS.published.key, body: "Learn the basics of web development with HTML! Master the structure and tags needed to build professional websites from scratch.", }, @@ -701,7 +699,6 @@ export const niceCourses: NiceCourseData[] = [ { itemType: LESSON_ITEM_TYPE.file.key, type: LESSON_FILE_TYPE.external_video.key, - url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", title: "Introduction to JavaScript", state: STATUS.published.key, body: "Learn the basics of web development with HTML! Master the structure and tags needed to build professional websites from scratch.", @@ -710,13 +707,12 @@ export const niceCourses: NiceCourseData[] = [ itemType: LESSON_ITEM_TYPE.question.key, questionType: QUESTION_TYPE.open_answer.key, questionBody: - "Your First JavaScript Function // Write a function to add two numbers\nfunction add(a, b) {\n // Your code here\n}", + "Your First JavaScript Function. Write a function to add two numbers\nfunction add(a, b)", solutionExplanation: "function add(a, b) {\n return a + b;\n}", state: STATUS.published.key, }, { itemType: LESSON_ITEM_TYPE.file.key, - url: "https://example.com/javascript-data-types-image", title: "JavaScript Data Types Overview", state: STATUS.published.key, type: LESSON_FILE_TYPE.presentation.key, @@ -789,7 +785,6 @@ export const niceCourses: NiceCourseData[] = [ itemType: LESSON_ITEM_TYPE.file.key, title: "Java Basics Video Tutorial", type: LESSON_FILE_TYPE.external_video.key, - url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", state: STATUS.published.key, body: "Learn the basics of web development with HTML! Master the structure and tags needed to build professional websites from scratch.", }, @@ -880,7 +875,6 @@ export const niceCourses: NiceCourseData[] = [ itemType: LESSON_ITEM_TYPE.file.key, title: "Java OOP Concepts Presentation", type: LESSON_FILE_TYPE.external_presentation.key, - url: "https://res.cloudinary.com/dinpapxzv/raw/upload/v1727104719/presentation_gp0o3d.pptx", state: STATUS.published.key, body: "Learn the basics of web development with HTML! Master the structure and tags needed to build professional websites from scratch.", }, @@ -1179,4 +1173,921 @@ export const niceCourses: NiceCourseData[] = [ }, ], }, + { + title: "Kotlin for Beginners: Modern Android Development", + description: + "Explore Kotlin, the modern and preferred language for Android development. This beginner-friendly course introduces Kotlin fundamentals and guides you through creating a basic Android app using Kotlin and Android Studio.", + state: STATUS.published.key, + priceInCents: 0, + category: "Mobile Development", + imageUrl: faker.image.urlPicsumPhotos(), + lessons: [ + { + type: LESSON_TYPE.multimedia.key, + title: "Getting Started with Kotlin Programming", + description: + "Learn Kotlin basics, including syntax, data types, and object-oriented programming concepts.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Introduction to Kotlin for Android", + body: "Kotlin is a modern, concise language used for Android development. In this lesson, you'll learn about Kotlin syntax and basic concepts for creating Android apps.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.file.key, + title: "Kotlin Basics Video Tutorial", + type: LESSON_FILE_TYPE.external_video.key, + state: STATUS.published.key, + body: "A video tutorial to help you learn Kotlin syntax, object-oriented principles, and how to apply them to Android development.", + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.single_choice.key, + questionBody: "Which keyword is used to declare a variable in Kotlin?", + state: STATUS.published.key, + questionAnswers: [ + { optionText: "var", isCorrect: true, position: 0 }, + { optionText: "val", isCorrect: false, position: 1 }, + { optionText: "let", isCorrect: false, position: 2 }, + { optionText: "data", isCorrect: false, position: 3 }, + ], + }, + ], + }, + { + type: LESSON_TYPE.multimedia.key, + title: "Building Your First App with Kotlin", + description: "Create a simple Android app using Kotlin and Android Studio.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Setting Up Your Android Studio Environment", + body: "Learn how to configure Android Studio for Kotlin development and create your first Android project.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.file.key, + title: "Creating a Simple Kotlin App", + type: LESSON_FILE_TYPE.external_presentation.key, + state: STATUS.published.key, + body: "A step-by-step guide to building your first Android app using Kotlin.", + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.fill_in_the_blanks_text.key, + questionBody: + "In Kotlin, [word] are immutable variables, while [word] are mutable variables.", + state: STATUS.published.key, + solutionExplanation: + "

In Kotlin, val are immutable variables, while var are mutable variables.

", + questionAnswers: [ + { optionText: "val", isCorrect: true, position: 0 }, + { optionText: "var", isCorrect: true, position: 1 }, + ], + }, + ], + }, + ], + }, + { + title: "Mathematics for Beginners: Building a Strong Foundation", + description: + "Learn essential math concepts with this beginner-friendly course. Covering fundamental topics like arithmetic, geometry, and algebra, this course is designed to make math accessible and enjoyable. By the end, you'll have a solid understanding of foundational math skills needed for advanced learning.", + state: STATUS.published.key, + priceInCents: 0, + category: "Mathematics", + imageUrl: faker.image.urlPicsumPhotos(), + lessons: [ + { + type: LESSON_TYPE.multimedia.key, + title: "Arithmetic Essentials: Numbers and Operations", + description: + "Explore the basics of arithmetic, including addition, subtraction, multiplication, and division. Learn how to perform these operations efficiently and understand their real-world applications.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Introduction to Arithmetic", + body: "Arithmetic is the foundation of mathematics. In this lesson, you'll learn about numbers, basic operations, and their properties.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.open_answer.key, + questionBody: + "Why is arithmetic considered the foundation of mathematics? Provide an example of its application in everyday life.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.file.key, + title: "Basic Arithmetic Video Tutorial", + type: LESSON_FILE_TYPE.external_video.key, + state: STATUS.published.key, + body: "Learn the basics of arithmetic operations and how to use them in problem-solving scenarios.", + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.fill_in_the_blanks_text.key, + questionBody: + "In arithmetic, [word] is the result of addition, while [word] is the result of subtraction.", + state: STATUS.published.key, + solutionExplanation: + "

In arithmetic, sum is the result of addition, while difference is the result of subtraction.

", + questionAnswers: [ + { optionText: "sum", isCorrect: true, position: 0 }, + { optionText: "difference", isCorrect: true, position: 1 }, + ], + }, + ], + }, + { + type: LESSON_TYPE.multimedia.key, + title: "Geometry Basics: Shapes and Measurements", + description: + "Discover the world of geometry by learning about shapes, angles, and measurements. Understand how geometry is used in design, architecture, and more.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Understanding Geometry", + body: "Geometry involves the study of shapes, sizes, and the properties of space. In this lesson, you'll learn about basic geometric figures and their properties.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.file.key, + title: "Geometric Shapes Presentation", + type: LESSON_FILE_TYPE.external_presentation.key, + state: STATUS.published.key, + body: "Explore various geometric shapes, their formulas for area and perimeter, and their real-life applications.", + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.single_choice.key, + questionBody: "Which formula is used to calculate the area of a rectangle?", + state: STATUS.published.key, + questionAnswers: [ + { optionText: "length × width", isCorrect: true, position: 0 }, + { optionText: "length + width", isCorrect: false, position: 1 }, + { optionText: "length × height", isCorrect: false, position: 2 }, + { optionText: "2 × (length + width)", isCorrect: false, position: 3 }, + ], + }, + ], + }, + { + type: LESSON_TYPE.multimedia.key, + title: " Algebra Introduction: Solving for the Unknown", + description: + "Learn the basics of algebra, including variables, expressions, and equations. Master the skills needed to solve simple algebraic problems.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Getting Started with Algebra", + body: "Algebra helps us solve problems by finding unknown values. In this lesson, you'll learn about variables, expressions, and simple equations.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.fill_in_the_blanks_text.key, + questionBody: + "In algebra, [word] represent unknown values, while [word] are mathematical phrases that combine numbers and variables.", + state: STATUS.published.key, + solutionExplanation: + "

In algebra, variables represent unknown values, while expressions are mathematical phrases that combine numbers and variables.

", + questionAnswers: [ + { optionText: "variables", isCorrect: true, position: 0 }, + { optionText: "expressions", isCorrect: true, position: 1 }, + ], + }, + { + itemType: LESSON_ITEM_TYPE.file.key, + title: "Basic Algebra Video Guide", + type: LESSON_FILE_TYPE.external_video.key, + state: STATUS.published.key, + body: "Learn to solve basic algebraic equations and understand how to work with variables.", + }, + ], + }, + { + type: LESSON_TYPE.quiz.key, + title: "Mathematics Basics Quiz: Test Your Knowledge", + description: + "Evaluate your understanding of arithmetic, geometry, and algebra with this comprehensive quiz.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.single_choice.key, + questionBody: "Which of the following is an example of a geometric shape?", + state: STATUS.published.key, + questionAnswers: [ + { optionText: "Triangle", isCorrect: true, position: 0 }, + { optionText: "Variable", isCorrect: false, position: 1 }, + { optionText: "Equation", isCorrect: false, position: 2 }, + { optionText: "Sum", isCorrect: false, position: 3 }, + ], + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.multiple_choice.key, + questionBody: + "Which operations are included in basic arithmetic? (Select all that apply)", + state: STATUS.published.key, + questionAnswers: [ + { optionText: "Addition", isCorrect: true, position: 0 }, + { optionText: "Subtraction", isCorrect: true, position: 1 }, + { optionText: "Multiplication", isCorrect: true, position: 2 }, + { optionText: "Division", isCorrect: true, position: 3 }, + { optionText: "Integration", isCorrect: false, position: 4 }, + ], + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.fill_in_the_blanks_text.key, + questionBody: + "In algebra, [word] are used to represent unknowns, while [word] can be solved to find their values.", + state: STATUS.published.key, + solutionExplanation: + "

In algebra, variables are used to represent unknowns, while equations can be solved to find their values.

", + questionAnswers: [ + { optionText: "variables", isCorrect: true, position: 0 }, + { optionText: "equations", isCorrect: true, position: 1 }, + ], + }, + ], + }, + ], + }, + { + title: "English Basics: Building a Strong Foundation", + description: + "Learn the fundamentals of English with this beginner-friendly course. From grammar to vocabulary, you'll gain the essential skills needed for effective communication in English. By the end of the course, you'll be equipped with the confidence to navigate everyday conversations and writing tasks with ease.", + state: STATUS.published.key, + priceInCents: 0, + category: "Language Learning", + imageUrl: faker.image.urlPicsumPhotos(), + lessons: [ + { + type: LESSON_TYPE.multimedia.key, + title: "Mastering Basic Grammar Rules", + description: + "This lesson focuses on the foundational rules of English grammar, including sentence structure, parts of speech, and common mistakes to avoid.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Introduction to English Grammar", + body: "Learn the essential grammar rules that form the backbone of English communication, covering nouns, verbs, adjectives, and more.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Sentence Structure Basics", + body: "Explore how sentences are structured, including subject-verb agreement and word order in affirmative, negative, and question forms.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.open_answer.key, + questionBody: "Explain the difference between a noun and a verb in a sentence.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.file.key, + title: "Grammar Rules Video Tutorial", + type: LESSON_FILE_TYPE.external_video.key, + state: STATUS.published.key, + body: "Watch this tutorial to get a comprehensive overview of essential English grammar rules.", + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.fill_in_the_blanks_dnd.key, + questionBody: "Fill in the blanks: 'She [word] to the store yesterday.", + state: STATUS.published.key, + solutionExplanation: "She went to the store yesterday.", + questionAnswers: [ + { + optionText: "went", + isCorrect: true, + position: 0, + }, + { + optionText: "go", + isCorrect: false, + position: 1, + }, + ], + }, + ], + }, + { + type: LESSON_TYPE.multimedia.key, + title: "Building Vocabulary for Beginners", + description: + "Learn how to expand your vocabulary with everyday English words and phrases, essential for building conversations.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Common English Words and Phrases", + body: "A beginner-friendly list of common English words and phrases you can use in daily conversations.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Synonyms and Antonyms", + body: "Learn about the importance of synonyms and antonyms in expanding your vocabulary and making your speech more varied.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.file.key, + title: "English Vocabulary Expansion Presentation", + type: LESSON_FILE_TYPE.external_presentation.key, + state: STATUS.published.key, + body: "A comprehensive slide presentation on expanding your vocabulary.", + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.single_choice.key, + questionBody: "Which word is the synonym of 'happy'?", + state: STATUS.published.key, + questionAnswers: [ + { + optionText: "Joyful", + isCorrect: true, + position: 0, + }, + { + optionText: "Sad", + isCorrect: false, + position: 1, + }, + { + optionText: "Angry", + isCorrect: false, + position: 2, + }, + ], + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.fill_in_the_blanks_dnd.key, + questionBody: "I [word] to the park every day.", + state: STATUS.published.key, + questionAnswers: [ + { + optionText: "go", + isCorrect: true, + position: 0, + }, + { + optionText: "went", + isCorrect: false, + position: 1, + }, + ], + }, + ], + }, + { + type: LESSON_TYPE.multimedia.key, + title: "Mastering Pronunciation and Accent", + description: + "In this lesson, you’ll learn how to improve your English pronunciation and reduce common accent barriers.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Essential Pronunciation Tips", + body: "Learn how to pronounce English words correctly and improve your accent with practical tips and exercises.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Common Pronunciation Mistakes", + body: "Identify and work on common pronunciation challenges for non-native English speakers.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.single_choice.key, + questionBody: + "Which of the following sounds is most commonly mispronounced by non-native English speakers?", + state: STATUS.published.key, + questionAnswers: [ + { + optionText: "Th", + isCorrect: true, + position: 0, + }, + { + optionText: "S", + isCorrect: false, + position: 1, + }, + { + optionText: "K", + isCorrect: false, + position: 2, + }, + ], + }, + { + itemType: LESSON_ITEM_TYPE.file.key, + title: "Pronunciation and Accent Video Tutorial", + type: LESSON_FILE_TYPE.external_video.key, + state: STATUS.published.key, + body: "A step-by-step video guide on mastering English pronunciation.", + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.fill_in_the_blanks_text.key, + questionBody: "I love [word] (swimming/swim).", + state: STATUS.published.key, + solutionExplanation: "I love swimming (swimming/swim).", + questionAnswers: [ + { + optionText: "swimming", + isCorrect: true, + position: 0, + }, + ], + }, + ], + }, + { + type: LESSON_TYPE.quiz.key, + title: "English Basics Quiz", + description: + "This quiz tests your knowledge of basic English grammar, vocabulary, and pronunciation.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.single_choice.key, + questionBody: + "Which part of speech is the word 'quickly' in the sentence 'She ran quickly to the store'?", + state: STATUS.published.key, + questionAnswers: [ + { + optionText: "Adverb", + isCorrect: true, + position: 0, + }, + { + optionText: "Verb", + isCorrect: false, + position: 1, + }, + { + optionText: "Adjective", + isCorrect: false, + position: 2, + }, + ], + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.fill_in_the_blanks_text.key, + questionBody: "She [word] to the park every day.", + state: STATUS.published.key, + solutionExplanation: "She went to the park every day.", + questionAnswers: [ + { + optionText: "goes", + isCorrect: true, + position: 0, + }, + { + optionText: "went", + isCorrect: false, + position: 1, + }, + ], + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.single_choice.key, + questionBody: "What is the plural form of 'child'?", + state: STATUS.published.key, + questionAnswers: [ + { + optionText: "Children", + isCorrect: true, + position: 0, + }, + { + optionText: "Childs", + isCorrect: false, + position: 1, + }, + ], + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.single_choice.key, + questionBody: "Which of these words is a conjunction?", + state: STATUS.published.key, + questionAnswers: [ + { + optionText: "And", + isCorrect: true, + position: 0, + }, + { + optionText: "Run", + isCorrect: false, + position: 1, + }, + { + optionText: "Quickly", + isCorrect: false, + position: 2, + }, + ], + }, + ], + }, + ], + }, + { + title: "Advanced English: Mastering Complex Language Skills", + description: + "Take your English proficiency to the next level with this advanced course. Dive deep into advanced grammar structures, vocabulary, idiomatic expressions, and perfect your writing and speaking skills. By the end of this course, you'll have the tools to express yourself with clarity, sophistication, and confidence.", + state: STATUS.published.key, + priceInCents: 0, + category: "Language Learning", + imageUrl: faker.image.urlPicsumPhotos(), + lessons: [ + { + type: LESSON_TYPE.multimedia.key, + title: "Advanced Grammar: Perfecting Sentence Structures", + description: + "Explore advanced sentence structures, including complex and compound sentences, as well as the use of clauses and modifiers to improve writing and speaking.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Complex Sentences and Their Use", + body: "Learn how to form and use complex sentences to convey more detailed thoughts and ideas effectively.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Relative Clauses and Modifiers", + body: "A deep dive into relative clauses and modifiers, which help to add extra information to sentences.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.open_answer.key, + questionBody: "What is the difference between a relative clause and a noun clause?", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.file.key, + title: "Advanced Grammar Video Tutorial", + type: LESSON_FILE_TYPE.external_video.key, + state: STATUS.published.key, + body: "Watch this in-depth video to understand complex sentence structures and advanced grammar.", + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.fill_in_the_blanks_text.key, + questionBody: "The book [word] I borrowed yesterday was fascinating.", + state: STATUS.published.key, + solutionExplanation: + "The book that I borrowed yesterday was fascinating.", + questionAnswers: [ + { + optionText: "that", + isCorrect: true, + position: 0, + }, + { + optionText: "who", + isCorrect: false, + position: 1, + }, + ], + }, + ], + }, + { + type: LESSON_TYPE.multimedia.key, + title: "Vocabulary Expansion: Academic and Formal English", + description: + "Learn high-level vocabulary to express complex ideas in academic, professional, and formal settings.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Academic Vocabulary and Its Application", + body: "Master vocabulary words commonly used in academic papers, essays, and formal discussions.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Using Formal Language in Communication", + body: "Learn how to adjust your language for formal situations, such as presentations or professional meetings.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.file.key, + title: "Academic Vocabulary List", + type: LESSON_FILE_TYPE.external_presentation.key, + state: STATUS.published.key, + body: "Download this list of academic vocabulary and explore their meanings and usage in context.", + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.single_choice.key, + questionBody: "Which word is an example of academic vocabulary?", + state: STATUS.published.key, + questionAnswers: [ + { + optionText: "Analyze", + isCorrect: true, + position: 0, + }, + { + optionText: "Run", + isCorrect: false, + position: 1, + }, + { + optionText: "Quick", + isCorrect: false, + position: 2, + }, + ], + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.fill_in_the_blanks_text.key, + questionBody: "The results [word] the hypothesis.", + state: STATUS.published.key, + solutionExplanation: "The results support the hypothesis.", + questionAnswers: [ + { + optionText: "support", + isCorrect: true, + position: 0, + }, + ], + }, + ], + }, + { + type: LESSON_TYPE.multimedia.key, + title: "Mastering Idiomatic Expressions", + description: + "Enhance your language fluency by mastering common idiomatic expressions used in advanced English conversations.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Understanding Idioms in Context", + body: "Learn how idiomatic expressions are used in everyday conversations to sound more natural and fluent.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Common Idioms and Their Meanings", + body: "A list of frequently used idioms, their meanings, and examples of how to use them.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.open_answer.key, + questionBody: "What does the idiom 'break the ice' mean?", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.file.key, + title: "Idiomatic Expressions Video Tutorial", + type: LESSON_FILE_TYPE.external_video.key, + state: STATUS.published.key, + body: "Watch this video to learn how to use idiomatic expressions in real conversations.", + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.fill_in_the_blanks_text.key, + questionBody: "She was [word] when she heard the good news.", + state: STATUS.published.key, + solutionExplanation: + "She was over the moon when she heard the good news.", + questionAnswers: [ + { + optionText: "over the moon", + isCorrect: true, + position: 0, + }, + ], + }, + ], + }, + { + type: LESSON_TYPE.multimedia.key, + title: "Advanced Writing Skills: Crafting Cohesive Paragraphs", + description: + "Learn how to write complex, well-structured paragraphs that convey your ideas clearly and persuasively in advanced writing contexts.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Topic Sentences and Supporting Details", + body: "Learn how to craft a clear topic sentence and use supporting details effectively in your writing.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Transitions and Coherence in Writing", + body: "Understand the importance of transitions and coherence to make your paragraphs flow logically.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.file.key, + title: "Paragraph Writing Practice", + type: LESSON_FILE_TYPE.external_presentation.key, + state: STATUS.published.key, + body: "Download this practice worksheet to improve your paragraph writing skills.", + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.fill_in_the_blanks_text.key, + questionBody: + "The introduction [word] should [word] the main points [word] in the essay.", + state: STATUS.published.key, + solutionExplanation: + "The introduction paragraph should outline the main points discussed in the essay.", + questionAnswers: [ + { + optionText: "paragraph", + isCorrect: true, + position: 0, + }, + { + optionText: "outline", + isCorrect: true, + position: 1, + }, + { + optionText: "discussed", + isCorrect: true, + position: 2, + }, + ], + }, + ], + }, + { + type: LESSON_TYPE.multimedia.key, + title: "Public Speaking: Delivering a Persuasive Speech", + description: + "Develop your public speaking skills by learning how to structure and deliver a persuasive speech that captivates your audience.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Structuring a Persuasive Speech", + body: "Learn the key components of a persuasive speech, including introduction, body, and conclusion.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.text_block.key, + title: "Techniques for Engaging Your Audience", + body: "Discover techniques such as storytelling, rhetorical questions, and powerful language to keep your audience engaged.", + state: STATUS.published.key, + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.single_choice.key, + questionBody: "What is the purpose of the conclusion in a persuasive speech?", + state: STATUS.published.key, + questionAnswers: [ + { + optionText: "Summarize the main points", + isCorrect: true, + position: 0, + }, + { + optionText: "Introduce new information", + isCorrect: false, + position: 1, + }, + ], + }, + { + itemType: LESSON_ITEM_TYPE.file.key, + title: "Persuasive Speech Example", + type: LESSON_FILE_TYPE.external_video.key, + state: STATUS.published.key, + body: "Listen to this persuasive speech example to see effective techniques in action.", + }, + ], + }, + { + type: LESSON_TYPE.quiz.key, + title: "Advanced English Quiz: Test Your Knowledge", + description: + "Test your mastery of advanced English skills, including grammar, vocabulary, idioms, writing, and public speaking.", + state: STATUS.published.key, + imageUrl: faker.image.urlPicsumPhotos(), + isFree: false, + items: [ + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.single_choice.key, + questionBody: "Which sentence is an example of a complex sentence?", + state: STATUS.published.key, + questionAnswers: [ + { + optionText: "She went to the store, and he stayed home.", + isCorrect: false, + position: 0, + }, + { + optionText: "Although it was raining, she went for a walk.", + isCorrect: true, + position: 1, + }, + ], + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.single_choice.key, + questionBody: "Which idiom means 'to be very happy'?", + state: STATUS.published.key, + questionAnswers: [ + { + optionText: "On cloud nine", + isCorrect: true, + position: 0, + }, + { + optionText: "Hit the nail on the head", + isCorrect: false, + position: 1, + }, + ], + }, + { + itemType: LESSON_ITEM_TYPE.question.key, + questionType: QUESTION_TYPE.fill_in_the_blanks_text.key, + questionBody: "The manager will [word] the team meeting [word].", + state: STATUS.published.key, + solutionExplanation: + "The manager will lead the team meeting tomorrow.", + questionAnswers: [ + { + optionText: "lead", + isCorrect: true, + position: 0, + }, + { + optionText: "tomorrow", + isCorrect: true, + position: 1, + }, + ], + }, + ], + }, + ], + }, ]; diff --git a/apps/api/src/seed-helpers.ts b/apps/api/src/seed/seed-helpers.ts similarity index 68% rename from apps/api/src/seed-helpers.ts rename to apps/api/src/seed/seed-helpers.ts index c091f4b2..e3cb62f8 100644 --- a/apps/api/src/seed-helpers.ts +++ b/apps/api/src/seed/seed-helpers.ts @@ -13,19 +13,25 @@ import { textBlocks, } from "src/storage/schema"; -import { LESSON_ITEM_TYPE } from "./lessons/lesson.type"; -import { QUESTION_TYPE } from "./questions/schema/questions.types"; +import { LESSON_FILE_TYPE, LESSON_ITEM_TYPE } from "../lessons/lesson.type"; +import { QUESTION_TYPE } from "../questions/schema/questions.types"; -import type { DatabasePg } from "./common"; -import type { NiceCourseData } from "./utils/types/test-types"; +import type { DatabasePg } from "../common"; +import type { NiceCourseData } from "../utils/types/test-types"; export async function createNiceCourses( - adminUserId: string, + creatorUserIds: string[], db: DatabasePg, data: NiceCourseData[], ) { - for (const courseData of data) { - const [category] = await db + const createdCourses = []; + + for (let i = 0; i < data.length; i++) { + const courseData = data[i]; + const creatorIndex = i % creatorUserIds.length; + const creatorUserId = creatorUserIds[creatorIndex]; + + await db .insert(categories) .values({ id: crypto.randomUUID(), @@ -34,8 +40,14 @@ export async function createNiceCourses( createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) - .returning(); + .onConflictDoNothing(); + + const [category] = await db + .select() + .from(categories) + .where(eq(categories.title, courseData.category)); + const createdAt = faker.date.past({ years: 1, refDate: new Date() }).toISOString(); const [course] = await db .insert(courses) .values({ @@ -45,10 +57,10 @@ export async function createNiceCourses( imageUrl: courseData.imageUrl, state: courseData.state, priceInCents: courseData.priceInCents, - authorId: adminUserId, + authorId: creatorUserId, categoryId: category.id, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: createdAt, + updatedAt: createdAt, }) .returning(); @@ -61,10 +73,10 @@ export async function createNiceCourses( type: lessonData.type, description: lessonData.description, imageUrl: faker.image.urlPicsumPhotos(), - authorId: adminUserId, + authorId: creatorUserId, state: lessonData.state, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: createdAt, + updatedAt: createdAt, itemsCount: lessonData.items.filter( (item) => item.itemType !== LESSON_ITEM_TYPE.text_block.key, ).length, @@ -88,7 +100,7 @@ export async function createNiceCourses( body: item.body, archived: false, state: item.state, - authorId: adminUserId, + authorId: creatorUserId, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) @@ -108,9 +120,9 @@ export async function createNiceCourses( id: crypto.randomUUID(), title: item.title, type: item.type, - url: item.url, + url: getFileUrl(item.type), state: item.state, - authorId: adminUserId, + authorId: creatorUserId, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) @@ -155,7 +167,7 @@ export async function createNiceCourses( ? item.solutionExplanation || "Explanation will be provided after answering." : null, state: item.state, - authorId: adminUserId, + authorId: creatorUserId, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) @@ -185,7 +197,9 @@ export async function createNiceCourses( } } } + createdCourses.push(course); } + return createdCourses; } export async function seedTruncateAllTables(db: DatabasePg): Promise { @@ -208,3 +222,33 @@ export async function seedTruncateAllTables(db: DatabasePg): Promise { await tx.execute(sql`SET CONSTRAINTS ALL IMMEDIATE`); }); } + +const external_video_urls = [ + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/SubaruOutbackOnStreetAndDirt.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/VolkswagenGTIReview.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/WeAreGoingOnBullrun.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/WhatCarCanYouGetForAGrand.mp4", +]; + +const external_presentation_urls = [ + "https://res.cloudinary.com/dinpapxzv/raw/upload/v1727104719/presentation_gp0o3d.pptx", +]; + +function getFileUrl(fileType: string) { + if (fileType === LESSON_FILE_TYPE.external_video.key) { + return faker.helpers.arrayElement(external_video_urls); + } else if (fileType === LESSON_FILE_TYPE.external_presentation.key) { + return faker.helpers.arrayElement(external_presentation_urls); + } else { + return faker.internet.url(); + } +} diff --git a/apps/api/src/seed-prod.ts b/apps/api/src/seed/seed-prod.ts similarity index 92% rename from apps/api/src/seed-prod.ts rename to apps/api/src/seed/seed-prod.ts index 81398f7d..a9ad50ab 100644 --- a/apps/api/src/seed-prod.ts +++ b/apps/api/src/seed/seed-prod.ts @@ -4,11 +4,11 @@ import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; -import hashPassword from "./common/helpers/hashPassword"; -import { credentials, users } from "./storage/schema"; -import { USER_ROLES } from "./users/schemas/user-roles"; +import hashPassword from "../common/helpers/hashPassword"; +import { credentials, users } from "../storage/schema"; +import { USER_ROLES } from "../users/schemas/user-roles"; -import type { DatabasePg } from "./common"; +import type { DatabasePg } from "../common"; dotenv.config({ path: "./.env" }); diff --git a/apps/api/src/seed/seed-staging.ts b/apps/api/src/seed/seed-staging.ts new file mode 100644 index 00000000..8c51d533 --- /dev/null +++ b/apps/api/src/seed/seed-staging.ts @@ -0,0 +1,456 @@ +import { faker } from "@faker-js/faker"; +import { format, subMonths } from "date-fns"; +import * as dotenv from "dotenv"; +import { and, eq, sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { flatMap, now, sampleSize } from "lodash"; +import postgres from "postgres"; + +import hashPassword from "../common/helpers/hashPassword"; +import { STATES } from "../common/states"; +import { LESSON_ITEM_TYPE, LESSON_TYPE } from "../lessons/lesson.type"; +import { + courseLessons, + courses, + coursesSummaryStats, + courseStudentsStats, + credentials, + lessonItems, + lessons, + quizAttempts, + studentCourses, + studentLessonsProgress, + userDetails, + users, +} from "../storage/schema"; +import { STATUS } from "../storage/schema/utils"; +import { USER_ROLES } from "../users/schemas/user-roles"; + +import { e2eCourses } from "./e2e-data-seeds"; +import { niceCourses } from "./nice-data-seeds"; +import { createNiceCourses, seedTruncateAllTables } from "./seed-helpers"; +import { admin, students, teachers } from "./users-seed"; + +import type { UsersSeed } from "./seed.type"; +import type { DatabasePg, UUIDType } from "../common"; + +dotenv.config({ path: "./.env" }); + +if (!("DATABASE_URL" in process.env)) { + throw new Error("DATABASE_URL not found on .env"); +} + +const connectionString = process.env.DATABASE_URL!; +const sqlConnect = postgres(connectionString); +const db = drizzle(sqlConnect) as DatabasePg; + +async function createUsers(users: UsersSeed, password = faker.internet.password()) { + return Promise.all( + users.map(async (userData) => { + const userToCreate = { + id: faker.string.uuid(), + email: userData.email || faker.internet.email(), + firstName: userData.firstName || faker.person.firstName(), + lastName: userData.lastName || faker.person.lastName(), + role: userData.role || USER_ROLES.student, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const user = await createOrFindUser(userToCreate.email, password, userToCreate); + + return user; + }), + ); +} + +async function createOrFindUser(email: string, password: string, userData: any) { + const [existingUser] = await db.select().from(users).where(eq(users.email, email)); + if (existingUser) return existingUser; + + const [newUser] = await db.insert(users).values(userData).returning(); + + await insertCredential(newUser.id, password); + + if (newUser.role === USER_ROLES.admin || newUser.role === USER_ROLES.teacher) + await insertUserDetails(newUser.id); + + return newUser; +} + +async function insertCredential(userId: string, password: string) { + const credentialData = { + id: faker.string.uuid(), + userId, + password: await hashPassword(password), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + return (await db.insert(credentials).values(credentialData).returning())[0]; +} + +async function insertUserDetails(userId: string) { + return await db.insert(userDetails).values({ + userId, + description: faker.lorem.paragraph(3), + contactEmail: faker.internet.email(), + contactPhoneNumber: faker.phone.number(), + jobTitle: faker.person.jobTitle(), + }); +} + +async function createEntities( + table: any, + count: number, + dataGenerator: () => T, +): Promise { + const entities = Array.from({ length: count }, dataGenerator); + return db.insert(table).values(entities).returning(); +} + +async function createCoursesWithLessons( + adminUserIds: string[], + categories: any[], + existingFiles: any[], + existingTextBlocks: any[], + existingQuestions: any[], +) { + const coursesData = []; + const lessonsData = []; + const lessonItemsData = []; + const courseToLessonsMap = new Map(); + + for (let i = 0; i < 40; i++) { + const courseId = faker.string.uuid(); + const isPublished = i < 36; // First 36 courses will be published, rest will be drafts + + const monthsToSubtract = i % 12; + const createdDate = subMonths(now(), monthsToSubtract); + const formattedCreatedDate = format(createdDate, "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"); + + const course = { + id: courseId, + title: faker.lorem.sentence(3), + description: faker.lorem.paragraph(3), + imageUrl: faker.image.urlPicsumPhotos(), + state: isPublished ? STATUS.published.key : STATUS.draft.key, + priceInCents: i % 3 === 0 ? faker.number.int({ min: 0, max: 1000 }) : 0, + authorId: adminUserIds[i % 2 ? 0 : 1], + categoryId: faker.helpers.arrayElement(categories).id, + createdAt: formattedCreatedDate, + updatedAt: formattedCreatedDate, + }; + + coursesData.push(course); + + if (isPublished) { + const maxLessonCount = faker.number.int({ min: 3, max: 10 }); + const courseLessons = []; + for (let j = 0; j < maxLessonCount; j++) { + const lessonId = faker.string.uuid(); + const lesson = { + id: lessonId, + title: faker.lorem.sentence(3), + description: faker.lorem.paragraph(3), + imageUrl: faker.image.urlPicsumPhotos(), + authorId: adminUserIds[j % 2 ? 0 : 1], + state: STATUS.published.key, + createdAt: formattedCreatedDate, + updatedAt: formattedCreatedDate, + itemsCount: 0, + }; + courseLessons.push(lessonId); + + const newLessonItems = createLessonItems( + lessonId, + existingFiles, + existingTextBlocks, + existingQuestions, + ); + lessonItemsData.push(...newLessonItems); + + lesson.itemsCount = newLessonItems.filter( + (item) => item.lessonItemType !== LESSON_ITEM_TYPE.text_block.key, + ).length; + lessonsData.push(lesson); + } + courseToLessonsMap.set(courseId, courseLessons); + } else { + // For draft courses, create a mix of draft and published lessons + const courseLessons = []; + for (let j = 0; j < 2; j++) { + const lessonId = faker.string.uuid(); + const lesson = { + id: lessonId, + title: faker.lorem.sentence(3), + description: faker.lorem.paragraph(3), + imageUrl: faker.image.urlPicsumPhotos(), + authorId: adminUserIds[j % 2 ? 0 : 1], + state: STATUS.published.key, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + itemsCount: 0, + }; + courseLessons.push(lessonId); + + const newLessonItems = createLessonItems( + lessonId, + existingFiles, + existingTextBlocks, + existingQuestions, + ); + lessonItemsData.push(...newLessonItems); + + lesson.itemsCount = newLessonItems.length; + lessonsData.push(lesson); + } + courseToLessonsMap.set(courseId, courseLessons); + } + } + + const createdCourses = await db.insert(courses).values(coursesData).returning(); + await db.insert(lessons).values(lessonsData).returning(); + await db.insert(lessonItems).values(lessonItemsData); + + // Create course-lesson associations + const courseLessonsData = []; + for (const [courseId, lessonIds] of courseToLessonsMap.entries()) { + courseLessonsData.push( + ...lessonIds.map((lessonId) => ({ + id: faker.string.uuid(), + courseId, + lessonId, + })), + ); + } + await db.insert(courseLessons).values(courseLessonsData); + + return createdCourses; +} + +function createLessonItems(lessonId: string, files: any[], textBlocks: any[], questions: any[]) { + const allItems = [ + ...files.map((file) => ({ type: LESSON_ITEM_TYPE.file.key, item: file })), + ...textBlocks.map((textBlock) => ({ type: LESSON_ITEM_TYPE.text_block.key, item: textBlock })), + ...questions.map((question) => ({ type: LESSON_ITEM_TYPE.question.key, item: question })), + ]; + + const shuffledItems = faker.helpers.shuffle(allItems); + + const itemCount = faker.number.int({ min: 3, max: 10 }); + + return shuffledItems.slice(0, itemCount).map((item, index) => ({ + id: faker.string.uuid(), + lessonId: lessonId, + lessonItemId: item.item.id, + lessonItemType: item.type, + displayOrder: index + 1, + })); +} + +async function createStudentCourses(courses: any[], studentIds: string[]) { + const studentsCoursesList = studentIds.flatMap((studentId) => { + const courseCount = Math.floor(courses.length * 0.5); + const selectedCourses = sampleSize(courses, courseCount); + + return selectedCourses.map((course) => { + return { + id: faker.string.uuid(), + studentId: studentId, + courseId: course.id, + numberOfAssignments: faker.number.int({ min: 0, max: 10 }), + numberOfFinishedAssignments: faker.number.int({ min: 0, max: 10 }), + state: "not_started", + archived: false, + createdAt: course.createdAt, + updatedAt: course.updatedAt, + }; + }); + }); + + return db.insert(studentCourses).values(studentsCoursesList).returning(); +} + +async function createLessonProgress(userId: string) { + const courseLessonsList = await db + .select({ + lessonId: courseLessons.lessonId, + courseId: courseLessons.courseId, + createdAt: sql`${courses.createdAt}`, + lessonType: sql`${lessons.type}`, + }) + .from(courseLessons) + .leftJoin(courses, eq(courseLessons.courseId, courses.id)) + .leftJoin(studentCourses, eq(courses.id, studentCourses.courseId)) + .leftJoin(lessons, eq(courseLessons.lessonId, lessons.id)) + .where(eq(studentCourses.studentId, userId)); + + const lessonProgressList = courseLessonsList.map((courseLesson) => { + const lessonId = courseLesson.lessonId; + const courseId = courseLesson.courseId; + + return { + lessonId, + courseId, + studentId: userId, + completedLessonItemCount: 0, + createdAt: courseLesson.createdAt, + updatedAt: courseLesson.createdAt, + quizCompleted: courseLesson.lessonType === LESSON_TYPE.quiz.key ? false : null, + quizScore: courseLesson.lessonType === LESSON_TYPE.quiz.key ? 0 : null, + }; + }); + + return db.insert(studentLessonsProgress).values(lessonProgressList).returning(); +} + +async function createCoursesSummaryStats(courses: any[] = []) { + const createdCoursesSummaryStats = courses.map((course) => ({ + authorId: course.authorId, + courseId: course.id, + freePurchasedCount: faker.number.int({ min: 20, max: 40 }), + paidPurchasedCount: faker.number.int({ min: 20, max: 40 }), + paidPurchasedAfterFreemiumCount: faker.number.int({ min: 0, max: 20 }), + completedFreemiumStudentCount: faker.number.int({ min: 40, max: 60 }), + completedCourseStudentCount: faker.number.int({ min: 0, max: 20 }), + })); + + return db.insert(coursesSummaryStats).values(createdCoursesSummaryStats); +} + +async function createQuizAttempts(userId: string) { + const quizzes = await db + .select({ courseId: courses.id, lessonId: lessons.id, lessonItemsCount: lessons.itemsCount }) + .from(courses) + .innerJoin(courseLessons, eq(courses.id, courseLessons.courseId)) + .innerJoin(lessons, eq(courseLessons.lessonId, lessons.id)) + .where(and(eq(courses.state, STATES.published), eq(lessons.type, LESSON_TYPE.quiz.key))); + + const createdQuizAttempts = quizzes.map((quiz) => { + const correctAnswers = faker.number.int({ min: 0, max: quiz.lessonItemsCount }); + + return { + userId, + courseId: quiz.courseId, + lessonId: quiz.lessonId, + correctAnswers: correctAnswers, + wrongAnswers: quiz.lessonItemsCount - correctAnswers, + score: Math.round((correctAnswers / quiz.lessonItemsCount) * 100), + }; + }); + + return db.insert(quizAttempts).values(createdQuizAttempts); +} + +function getLast12Months(): Array<{ month: number; year: number; formattedDate: string }> { + const today = new Date(); + return Array.from({ length: 12 }, (_, index) => { + const date = subMonths(today, index); + return { + month: date.getMonth(), + year: date.getFullYear(), + formattedDate: format(date, "MMMM yyyy"), + }; + }).reverse(); +} + +async function createCourseStudentsStats() { + const createdCourses = await db + .select({ + courseId: courses.id, + authorId: courses.authorId, + }) + .from(courses) + .where(eq(courses.state, STATES.published)); + + const twelveMonthsAgoArray = getLast12Months(); + + const createdTwelveMonthsAgoStats = flatMap(createdCourses, (course) => + twelveMonthsAgoArray.map((monthDetails) => ({ + courseId: course.courseId, + authorId: course.authorId, + newStudentsCount: faker.number.int({ min: 5, max: 25 }), + month: monthDetails.month, + year: monthDetails.year, + })), + ); + + await db.insert(courseStudentsStats).values(createdTwelveMonthsAgoStats); +} + +async function seedStaging() { + await seedTruncateAllTables(db); + + try { + const createdStudents = await createUsers(students, "password"); + const [createdAdmin] = await createUsers(admin, "password"); + const createdTeachers = await createUsers(teachers, "password"); + await createUsers( + [ + { + email: "student0@example.com", + firstName: faker.person.firstName(), + lastName: "Student", + role: USER_ROLES.student, + }, + ], + "password", + ); + + const createdStudentIds = createdStudents.map((student) => student.id); + const creatorCourseIds = [createdAdmin.id, ...createdTeachers.map((teacher) => teacher.id)]; + + console.log("Created or found admin user:", createdAdmin); + console.log("Created or found students user:", createdStudents); + console.log("Created or found teachers user:", createdTeachers); + + const createdCourses = await createNiceCourses(creatorCourseIds, db, niceCourses); + console.log("✨✨✨Created created nice courses✨✨✨"); + await createNiceCourses([createdAdmin.id], db, e2eCourses); + console.log("🧪 Created e2e courses"); + + console.log("Selected random courses for student from createdCourses"); + await createStudentCourses(createdCourses, createdStudentIds); + console.log("Created student courses"); + + await Promise.all( + createdStudentIds.map(async (studentId) => { + await createLessonProgress(studentId); + }), + ); + console.log("Created student lesson progress"); + + // TODO: change to function working on data from database as in real app + await createCoursesSummaryStats(createdCourses); + + await Promise.all( + createdStudentIds.map(async (studentId) => { + await createQuizAttempts(studentId); + }), + ); + await createCourseStudentsStats(); + console.log("Created student course students stats"); + console.log("Seeding completed successfully"); + } catch (error) { + console.error("Seeding failed:", error); + } finally { + console.log("Closing database connection"); + try { + await sqlConnect.end(); + console.log("Database connection closed successfully."); + } catch (error) { + console.error("Error closing the database connection:", error); + } + } +} + +if (require.main === module) { + seedStaging() + .then(() => process.exit(0)) + .catch((error) => { + console.error("An error occurred:", error); + process.exit(1); + }); +} + +export default seedStaging; diff --git a/apps/api/src/seed.ts b/apps/api/src/seed/seed.ts similarity index 96% rename from apps/api/src/seed.ts rename to apps/api/src/seed/seed.ts index 722d1b94..987a0f8d 100644 --- a/apps/api/src/seed.ts +++ b/apps/api/src/seed/seed.ts @@ -6,12 +6,9 @@ import { drizzle } from "drizzle-orm/postgres-js"; import { flatMap, now, sampleSize } from "lodash"; import postgres from "postgres"; -import hashPassword from "./common/helpers/hashPassword"; -import { STATES } from "./common/states"; -import { e2eCourses } from "./e2e-data-seeds"; -import { LESSON_FILE_TYPE, LESSON_ITEM_TYPE, LESSON_TYPE } from "./lessons/lesson.type"; -import { niceCourses } from "./nice-data-seeds"; -import { createNiceCourses, seedTruncateAllTables } from "./seed-helpers"; +import hashPassword from "../common/helpers/hashPassword"; +import { STATES } from "../common/states"; +import { LESSON_FILE_TYPE, LESSON_ITEM_TYPE, LESSON_TYPE } from "../lessons/lesson.type"; import { categories, courseLessons, @@ -30,11 +27,15 @@ import { textBlocks, userDetails, users, -} from "./storage/schema"; -import { STATUS } from "./storage/schema/utils"; -import { USER_ROLES } from "./users/schemas/user-roles"; +} from "../storage/schema"; +import { STATUS } from "../storage/schema/utils"; +import { USER_ROLES } from "../users/schemas/user-roles"; + +import { e2eCourses } from "./e2e-data-seeds"; +import { niceCourses } from "./nice-data-seeds"; +import { createNiceCourses, seedTruncateAllTables } from "./seed-helpers"; -import type { DatabasePg, UUIDType } from "./common"; +import type { DatabasePg, UUIDType } from "../common"; dotenv.config({ path: "./.env" }); @@ -399,8 +400,8 @@ async function seed() { const adminUser = await createOrFindUser("admin@example.com", "password", { id: faker.string.uuid(), email: "admin@example.com", - firstName: "Admin", - lastName: "User", + firstName: faker.person.firstName(), + lastName: "Admin", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), role: USER_ROLES.admin, @@ -409,8 +410,8 @@ async function seed() { const studentUser = await createOrFindUser("user@example.com", "password", { id: faker.string.uuid(), email: "user@example.com", - firstName: "Student", - lastName: "User", + firstName: faker.person.firstName(), + lastName: "Student", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), role: USER_ROLES.student, @@ -419,8 +420,8 @@ async function seed() { const teacherUser = await createOrFindUser("teacher@example.com", "password", { id: faker.string.uuid(), email: "teacher@example.com", - firstName: "Teacher", - lastName: "User", + firstName: faker.person.firstName(), + lastName: "Teacher", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), role: USER_ROLES.teacher, @@ -433,9 +434,9 @@ async function seed() { await createUsers(5); console.log("Created users with credentials"); - await createNiceCourses(adminUser.id, db, niceCourses); + await createNiceCourses([adminUser.id], db, niceCourses); console.log("✨✨✨Created created nice courses✨✨✨"); - await createNiceCourses(adminUser.id, db, e2eCourses); + await createNiceCourses([adminUser.id], db, e2eCourses); console.log("🧪 Created e2e courses"); const createdCategories = await createEntities( diff --git a/apps/api/src/seed/seed.type.ts b/apps/api/src/seed/seed.type.ts new file mode 100644 index 00000000..e964c3f2 --- /dev/null +++ b/apps/api/src/seed/seed.type.ts @@ -0,0 +1,14 @@ +import { Type } from "@sinclair/typebox"; + +import type { Static } from "@sinclair/typebox"; + +export const user = Type.Object({ + role: Type.String(), + email: Type.String(), + firstName: Type.String(), + lastName: Type.String(), +}); + +const userArray = Type.Array(user); + +export type UsersSeed = Static; diff --git a/apps/api/src/truncateTables.ts b/apps/api/src/seed/truncateTables.ts similarity index 94% rename from apps/api/src/truncateTables.ts rename to apps/api/src/seed/truncateTables.ts index 23ee8da5..6fe0cdb5 100644 --- a/apps/api/src/truncateTables.ts +++ b/apps/api/src/seed/truncateTables.ts @@ -4,7 +4,7 @@ import postgres from "postgres"; import { seedTruncateAllTables } from "./seed-helpers"; -import type { DatabasePg } from "./common"; +import type { DatabasePg } from "../common"; dotenv.config({ path: "./.env" }); diff --git a/apps/api/src/seed/users-seed.ts b/apps/api/src/seed/users-seed.ts new file mode 100644 index 00000000..218ad1cb --- /dev/null +++ b/apps/api/src/seed/users-seed.ts @@ -0,0 +1,50 @@ +import { faker } from "@faker-js/faker"; + +import { USER_ROLES } from "src/users/schemas/user-roles"; + +import type { UsersSeed } from "./seed.type"; + +export const students: UsersSeed = [ + { + role: USER_ROLES.student, + email: "student@example.com", + firstName: faker.person.firstName(), + lastName: "Student", + }, + { + role: USER_ROLES.student, + email: "student2@example.com", + firstName: faker.person.firstName(), + lastName: "Student", + }, + { + role: USER_ROLES.student, + email: "student3@example.com", + firstName: faker.person.firstName(), + lastName: "Student", + }, +]; + +export const admin: UsersSeed = [ + { + role: USER_ROLES.admin, + email: "admin@example.com", + firstName: faker.person.firstName(), + lastName: "Admin", + }, +]; + +export const teachers: UsersSeed = [ + { + role: USER_ROLES.teacher, + email: "teacher@example.com", + firstName: faker.person.firstName(), + lastName: "Teacher", + }, + { + role: USER_ROLES.teacher, + email: "teacher2@example.com", + firstName: faker.person.firstName(), + lastName: "Teacher", + }, +]; diff --git a/apps/api/src/utils/types/test-types.ts b/apps/api/src/utils/types/test-types.ts index f04dd982..0efe87e6 100644 --- a/apps/api/src/utils/types/test-types.ts +++ b/apps/api/src/utils/types/test-types.ts @@ -26,7 +26,7 @@ const niceCourseData = Type.Intersect([ items: Type.Array( Type.Union([ Type.Intersect([ - Type.Omit(lessonItemFileSchema, ["id", "archived", "authorId"]), + Type.Omit(lessonItemFileSchema, ["id", "archived", "authorId", "url"]), Type.Object({ itemType: Type.Literal(LESSON_ITEM_TYPE.file.key), }),