87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]",
92 | className
93 | )}
94 | {...props}
95 | />
96 | ))
97 | TableCell.displayName = "TableCell"
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | TableCaption.displayName = "TableCaption"
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | }
121 |
--------------------------------------------------------------------------------
/client/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/client/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<"textarea">
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | )
19 | })
20 | Textarea.displayName = "Textarea"
21 |
22 | export { Textarea }
23 |
--------------------------------------------------------------------------------
/client/src/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TogglePrimitive from "@radix-ui/react-toggle"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const toggleVariants = cva(
10 | "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
11 | {
12 | variants: {
13 | variant: {
14 | default: "bg-transparent",
15 | outline:
16 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
17 | },
18 | size: {
19 | default: "h-9 px-2 min-w-9",
20 | sm: "h-8 px-1.5 min-w-8",
21 | lg: "h-10 px-2.5 min-w-10",
22 | },
23 | },
24 | defaultVariants: {
25 | variant: "default",
26 | size: "default",
27 | },
28 | }
29 | )
30 |
31 | const Toggle = React.forwardRef<
32 | React.ElementRef,
33 | React.ComponentPropsWithoutRef &
34 | VariantProps
35 | >(({ className, variant, size, ...props }, ref) => (
36 |
41 | ))
42 |
43 | Toggle.displayName = TogglePrimitive.Root.displayName
44 |
45 | export { Toggle, toggleVariants }
46 |
--------------------------------------------------------------------------------
/client/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
19 |
28 |
29 | ))
30 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
31 |
32 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
33 |
--------------------------------------------------------------------------------
/client/src/hooks/use-mobile.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | const MOBILE_BREAKPOINT = 768
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(undefined)
7 |
8 | React.useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12 | }
13 | mql.addEventListener("change", onChange)
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15 | return () => mql.removeEventListener("change", onChange)
16 | }, [])
17 |
18 | return !!isMobile
19 | }
20 |
--------------------------------------------------------------------------------
/client/src/hooks/useCarousel.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 |
3 | interface UseCarouselProps {
4 | totalImages: number;
5 | interval?: number;
6 | }
7 |
8 | export const useCarousel = ({
9 | totalImages,
10 | interval = 5000,
11 | }: UseCarouselProps) => {
12 | const [currentImage, setCurrentImage] = useState(0);
13 |
14 | useEffect(() => {
15 | const timer = setInterval(() => {
16 | setCurrentImage((prev) => (prev + 1) % totalImages);
17 | }, interval);
18 |
19 | return () => clearInterval(timer);
20 | }, [totalImages, interval]);
21 |
22 | return currentImage;
23 | };
24 |
--------------------------------------------------------------------------------
/client/src/hooks/useCheckoutNavigation.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useUser } from "@clerk/nextjs";
4 | import { useRouter, useSearchParams } from "next/navigation";
5 | import React, { useCallback, useEffect } from "react";
6 |
7 | export const useCheckoutNavigation = () => {
8 | const router = useRouter();
9 | const searchParams = useSearchParams();
10 | const { isLoaded, isSignedIn } = useUser();
11 |
12 | const courseId = searchParams.get("id") ?? "";
13 | const checkoutStep = parseInt(searchParams.get("step") ?? "1", 10);
14 |
15 | const navigateToStep = useCallback(
16 | (step: number) => {
17 | const newStep = Math.min(Math.max(1, step), 3);
18 | const showSignUp = isSignedIn ? "true" : "false";
19 |
20 | router.push(
21 | `/checkout?step=${newStep}&id=${courseId}&showSignUp=${showSignUp}`,
22 | {
23 | scroll: false,
24 | }
25 | );
26 | },
27 | [courseId, isSignedIn, router]
28 | );
29 |
30 | useEffect(() => {
31 | if (isLoaded && !isSignedIn && checkoutStep > 1) {
32 | navigateToStep(1);
33 | }
34 | }, [isLoaded, isSignedIn, checkoutStep, navigateToStep]);
35 |
36 | return { checkoutStep, navigateToStep };
37 | };
38 |
--------------------------------------------------------------------------------
/client/src/hooks/useCourseProgressData.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useParams } from "next/navigation";
3 | import {
4 | useGetCourseQuery,
5 | useGetUserCourseProgressQuery,
6 | useUpdateUserCourseProgressMutation,
7 | } from "@/state/api";
8 | import { useUser } from "@clerk/nextjs";
9 |
10 | export const useCourseProgressData = () => {
11 | const { courseId, chapterId } = useParams();
12 | const { user, isLoaded } = useUser();
13 | const [hasMarkedComplete, setHasMarkedComplete] = useState(false);
14 | const [updateProgress] = useUpdateUserCourseProgressMutation();
15 |
16 | const { data: course, isLoading: courseLoading } = useGetCourseQuery(
17 | (courseId as string) ?? "",
18 | {
19 | skip: !courseId,
20 | }
21 | );
22 |
23 | const { data: userProgress, isLoading: progressLoading } =
24 | useGetUserCourseProgressQuery(
25 | {
26 | userId: user?.id ?? "",
27 | courseId: (courseId as string) ?? "",
28 | },
29 | {
30 | skip: !isLoaded || !user || !courseId,
31 | }
32 | );
33 |
34 | const isLoading = !isLoaded || courseLoading || progressLoading;
35 |
36 | const currentSection = course?.sections.find((s) =>
37 | s.chapters.some((c) => c.chapterId === chapterId)
38 | );
39 |
40 | const currentChapter = currentSection?.chapters.find(
41 | (c) => c.chapterId === chapterId
42 | );
43 |
44 | const isChapterCompleted = () => {
45 | if (!currentSection || !currentChapter || !userProgress?.sections)
46 | return false;
47 |
48 | const section = userProgress.sections.find(
49 | (s) => s.sectionId === currentSection.sectionId
50 | );
51 | return (
52 | section?.chapters.some(
53 | (c) => c.chapterId === currentChapter.chapterId && c.completed
54 | ) ?? false
55 | );
56 | };
57 |
58 | const updateChapterProgress = (
59 | sectionId: string,
60 | chapterId: string,
61 | completed: boolean
62 | ) => {
63 | if (!user) return;
64 |
65 | const updatedSections = [
66 | {
67 | sectionId,
68 | chapters: [
69 | {
70 | chapterId,
71 | completed,
72 | },
73 | ],
74 | },
75 | ];
76 |
77 | updateProgress({
78 | userId: user.id,
79 | courseId: (courseId as string) ?? "",
80 | progressData: {
81 | sections: updatedSections,
82 | },
83 | });
84 | };
85 |
86 | return {
87 | user,
88 | courseId,
89 | chapterId,
90 | course,
91 | userProgress,
92 | currentSection,
93 | currentChapter,
94 | isLoading,
95 | isChapterCompleted,
96 | updateChapterProgress,
97 | hasMarkedComplete,
98 | setHasMarkedComplete,
99 | };
100 | };
101 |
--------------------------------------------------------------------------------
/client/src/hooks/useCurrentCourse.ts:
--------------------------------------------------------------------------------
1 | import { useGetCourseQuery } from "@/state/api";
2 | import { useSearchParams } from "next/navigation";
3 |
4 | export const useCurrentCourse = () => {
5 | const searchParams = useSearchParams();
6 | const courseId = searchParams.get("id") ?? "";
7 | const { data: course, ...rest } = useGetCourseQuery(courseId);
8 |
9 | return { course, courseId, ...rest };
10 | };
11 |
--------------------------------------------------------------------------------
/client/src/lib/schemas.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod";
2 |
3 | // Course Editor Schemas
4 | export const courseSchema = z.object({
5 | courseTitle: z.string().min(1, "Title is required"),
6 | courseDescription: z.string().min(1, "Description is required"),
7 | courseCategory: z.string().min(1, "Category is required"),
8 | coursePrice: z.string(),
9 | courseStatus: z.boolean(),
10 | });
11 |
12 | export type CourseFormData = z.infer;
13 |
14 | // Chapter Schemas
15 | export const chapterSchema = z.object({
16 | title: z.string().min(2, "Title must be at least 2 characters"),
17 | content: z.string().min(10, "Content must be at least 10 characters"),
18 | video: z.union([z.string(), z.instanceof(File)]).optional(),
19 | });
20 |
21 | export type ChapterFormData = z.infer;
22 |
23 | // Section Schemas
24 | export const sectionSchema = z.object({
25 | title: z.string().min(2, "Title must be at least 2 characters"),
26 | description: z.string().min(10, "Description must be at least 10 characters"),
27 | });
28 |
29 | export type SectionFormData = z.infer;
30 |
31 | // Guest Checkout Schema
32 | export const guestSchema = z.object({
33 | email: z.string().email("Invalid email address"),
34 | });
35 |
36 | export type GuestFormData = z.infer;
37 |
38 | // Notification Settings Schema
39 | export const notificationSettingsSchema = z.object({
40 | courseNotifications: z.boolean(),
41 | emailAlerts: z.boolean(),
42 | smsAlerts: z.boolean(),
43 | notificationFrequency: z.enum(["immediate", "daily", "weekly"]),
44 | });
45 |
46 | export type NotificationSettingsFormData = z.infer<
47 | typeof notificationSettingsSchema
48 | >;
49 |
--------------------------------------------------------------------------------
/client/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
2 | import { NextResponse } from "next/server";
3 |
4 | const isStudentRoute = createRouteMatcher(["/user/(.*)"]);
5 | const isTeacherRoute = createRouteMatcher(["/teacher/(.*)"]);
6 |
7 | export default clerkMiddleware(async (auth, req) => {
8 | const { sessionClaims } = await auth();
9 | const userRole =
10 | (sessionClaims?.metadata as { userType: "student" | "teacher" })
11 | ?.userType || "student";
12 |
13 | if (isStudentRoute(req)) {
14 | if (userRole !== "student") {
15 | const url = new URL("/teacher/courses", req.url);
16 | return NextResponse.redirect(url);
17 | }
18 | }
19 |
20 | if (isTeacherRoute(req)) {
21 | if (userRole !== "teacher") {
22 | const url = new URL("/user/courses", req.url);
23 | return NextResponse.redirect(url);
24 | }
25 | }
26 | });
27 |
28 | export const config = {
29 | matcher: [
30 | // Skip Next.js internals and all static files, unless found in search params
31 | "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
32 | // Always run for API routes
33 | "/(api|trpc)(.*)",
34 | ],
35 | };
36 |
--------------------------------------------------------------------------------
/client/src/state/index.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit";
2 |
3 | interface InitialStateTypes {
4 | courseEditor: {
5 | sections: Section[];
6 | isChapterModalOpen: boolean;
7 | isSectionModalOpen: boolean;
8 | selectedSectionIndex: number | null;
9 | selectedChapterIndex: number | null;
10 | };
11 | }
12 |
13 | const initialState: InitialStateTypes = {
14 | courseEditor: {
15 | sections: [],
16 | isChapterModalOpen: false,
17 | isSectionModalOpen: false,
18 | selectedSectionIndex: null,
19 | selectedChapterIndex: null,
20 | },
21 | };
22 |
23 | export const globalSlice = createSlice({
24 | name: "global",
25 | initialState,
26 | reducers: {
27 | setSections: (state, action: PayloadAction) => {
28 | state.courseEditor.sections = action.payload;
29 | },
30 | openChapterModal: (
31 | state,
32 | action: PayloadAction<{
33 | sectionIndex: number | null;
34 | chapterIndex: number | null;
35 | }>
36 | ) => {
37 | state.courseEditor.isChapterModalOpen = true;
38 | state.courseEditor.selectedSectionIndex = action.payload.sectionIndex;
39 | state.courseEditor.selectedChapterIndex = action.payload.chapterIndex;
40 | },
41 | closeChapterModal: (state) => {
42 | state.courseEditor.isChapterModalOpen = false;
43 | state.courseEditor.selectedSectionIndex = null;
44 | state.courseEditor.selectedChapterIndex = null;
45 | },
46 |
47 | openSectionModal: (
48 | state,
49 | action: PayloadAction<{ sectionIndex: number | null }>
50 | ) => {
51 | state.courseEditor.isSectionModalOpen = true;
52 | state.courseEditor.selectedSectionIndex = action.payload.sectionIndex;
53 | },
54 | closeSectionModal: (state) => {
55 | state.courseEditor.isSectionModalOpen = false;
56 | state.courseEditor.selectedSectionIndex = null;
57 | },
58 |
59 | addSection: (state, action: PayloadAction) => {
60 | state.courseEditor.sections.push(action.payload);
61 | },
62 | editSection: (
63 | state,
64 | action: PayloadAction<{ index: number; section: Section }>
65 | ) => {
66 | state.courseEditor.sections[action.payload.index] =
67 | action.payload.section;
68 | },
69 | deleteSection: (state, action: PayloadAction) => {
70 | state.courseEditor.sections.splice(action.payload, 1);
71 | },
72 |
73 | addChapter: (
74 | state,
75 | action: PayloadAction<{ sectionIndex: number; chapter: Chapter }>
76 | ) => {
77 | state.courseEditor.sections[action.payload.sectionIndex].chapters.push(
78 | action.payload.chapter
79 | );
80 | },
81 | editChapter: (
82 | state,
83 | action: PayloadAction<{
84 | sectionIndex: number;
85 | chapterIndex: number;
86 | chapter: Chapter;
87 | }>
88 | ) => {
89 | state.courseEditor.sections[action.payload.sectionIndex].chapters[
90 | action.payload.chapterIndex
91 | ] = action.payload.chapter;
92 | },
93 | deleteChapter: (
94 | state,
95 | action: PayloadAction<{ sectionIndex: number; chapterIndex: number }>
96 | ) => {
97 | state.courseEditor.sections[action.payload.sectionIndex].chapters.splice(
98 | action.payload.chapterIndex,
99 | 1
100 | );
101 | },
102 | },
103 | });
104 |
105 | export const {
106 | setSections,
107 | openChapterModal,
108 | closeChapterModal,
109 | openSectionModal,
110 | closeSectionModal,
111 | addSection,
112 | editSection,
113 | deleteSection,
114 | addChapter,
115 | editChapter,
116 | deleteChapter,
117 | } = globalSlice.actions;
118 |
119 | export default globalSlice.reducer;
120 |
--------------------------------------------------------------------------------
/client/src/state/redux.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRef } from "react";
4 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
5 | import { combineReducers, configureStore } from "@reduxjs/toolkit";
6 | import { Provider } from "react-redux";
7 | import { setupListeners } from "@reduxjs/toolkit/query";
8 | import globalReducer from "@/state";
9 | import { api } from "@/state/api";
10 |
11 | /* REDUX STORE */
12 | const rootReducer = combineReducers({
13 | global: globalReducer,
14 | [api.reducerPath]: api.reducer,
15 | });
16 |
17 | export const makeStore = () => {
18 | return configureStore({
19 | reducer: rootReducer,
20 | middleware: (getDefaultMiddleware) =>
21 | getDefaultMiddleware({
22 | serializableCheck: {
23 | ignoredActions: [
24 | "api/executeMutation/pending",
25 | "api/executeMutation/fulfilled",
26 | "api/executeMutation/rejected",
27 | ],
28 | ignoredActionPaths: [
29 | "meta.arg.originalArgs.file",
30 | "meta.arg.originalArgs.formData",
31 | "payload.chapter.video",
32 | "meta.baseQueryMeta.request",
33 | "meta.baseQueryMeta.response",
34 | ],
35 | ignoredPaths: [
36 | "global.courseEditor.sections",
37 | "entities.videos.data",
38 | "meta.baseQueryMeta.request",
39 | "meta.baseQueryMeta.response",
40 | ],
41 | },
42 | }).concat(api.middleware),
43 | });
44 | };
45 |
46 | /* REDUX TYPES */
47 | export type AppStore = ReturnType;
48 | export type RootState = ReturnType;
49 | export type AppDispatch = AppStore["dispatch"];
50 | export const useAppDispatch = () => useDispatch();
51 | export const useAppSelector: TypedUseSelectorHook = useSelector;
52 |
53 | /* PROVIDER */
54 | export default function StoreProvider({
55 | children,
56 | }: {
57 | children: React.ReactNode;
58 | }) {
59 | const storeRef = useRef();
60 | if (!storeRef.current) {
61 | storeRef.current = makeStore();
62 | setupListeners(storeRef.current.dispatch);
63 | }
64 | return {children};
65 | }
66 |
--------------------------------------------------------------------------------
/client/src/types/index.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface PaymentMethod {
3 | methodId: string;
4 | type: string;
5 | lastFour: string;
6 | expiry: string;
7 | }
8 |
9 | interface UserSettings {
10 | theme?: "light" | "dark";
11 | emailAlerts?: boolean;
12 | smsAlerts?: boolean;
13 | courseNotifications?: boolean;
14 | notificationFrequency?: "immediate" | "daily" | "weekly";
15 | }
16 |
17 | interface User {
18 | userId: string;
19 | firstName?: string;
20 | lastName?: string;
21 | username?: string;
22 | email: string;
23 | publicMetadata: {
24 | userType: "teacher" | "student";
25 | };
26 | privateMetadata: {
27 | settings?: UserSettings;
28 | paymentMethods?: Array;
29 | defaultPaymentMethodId?: string;
30 | stripeCustomerId?: string;
31 | };
32 | unsafeMetadata: {
33 | bio?: string;
34 | urls?: string[];
35 | };
36 | }
37 |
38 | interface Course {
39 | courseId: string;
40 | teacherId: string;
41 | teacherName: string;
42 | title: string;
43 | description?: string;
44 | category: string;
45 | image?: string;
46 | price?: number; // Stored in cents (e.g., 4999 for $49.99)
47 | level: "Beginner" | "Intermediate" | "Advanced";
48 | status: "Draft" | "Published";
49 | sections: Section[];
50 | enrollments?: Array<{
51 | userId: string;
52 | }>;
53 | }
54 |
55 | interface Transaction {
56 | userId: string;
57 | transactionId: string;
58 | dateTime: string;
59 | courseId: string;
60 | paymentProvider: "stripe";
61 | paymentMethodId?: string;
62 | amount: number; // Stored in cents
63 | savePaymentMethod?: boolean;
64 | }
65 |
66 | interface DateRange {
67 | from: string | undefined;
68 | to: string | undefined;
69 | }
70 |
71 | interface UserCourseProgress {
72 | userId: string;
73 | courseId: string;
74 | enrollmentDate: string;
75 | overallProgress: number;
76 | sections: SectionProgress[];
77 | lastAccessedTimestamp: string;
78 | }
79 |
80 | type CreateUserArgs = Omit;
81 | type CreateCourseArgs = Omit;
82 | type CreateTransactionArgs = Omit;
83 |
84 | interface CourseCardProps {
85 | course: Course;
86 | onGoToCourse: (course: Course) => void;
87 | }
88 |
89 | interface TeacherCourseCardProps {
90 | course: Course;
91 | onEdit: (course: Course) => void;
92 | onDelete: (course: Course) => void;
93 | isOwner: boolean;
94 | }
95 |
96 | interface Comment {
97 | commentId: string;
98 | userId: string;
99 | text: string;
100 | timestamp: string;
101 | }
102 |
103 | interface Chapter {
104 | chapterId: string;
105 | title: string;
106 | content: string;
107 | video?: string | File;
108 | freePreview?: boolean;
109 | type: "Text" | "Quiz" | "Video";
110 | }
111 |
112 | interface ChapterProgress {
113 | chapterId: string;
114 | completed: boolean;
115 | }
116 |
117 | interface SectionProgress {
118 | sectionId: string;
119 | chapters: ChapterProgress[];
120 | }
121 |
122 | interface Section {
123 | sectionId: string;
124 | sectionTitle: string;
125 | sectionDescription?: string;
126 | chapters: Chapter[];
127 | }
128 |
129 | interface WizardStepperProps {
130 | currentStep: number;
131 | }
132 |
133 | interface AccordionSectionsProps {
134 | sections: Section[];
135 | }
136 |
137 | interface SearchCourseCardProps {
138 | course: Course;
139 | isSelected?: boolean;
140 | onClick?: () => void;
141 | }
142 |
143 | interface CoursePreviewProps {
144 | course: Course;
145 | }
146 |
147 | interface CustomFixedModalProps {
148 | isOpen: boolean;
149 | onClose: () => void;
150 | children: ReactNode;
151 | }
152 |
153 | interface HeaderProps {
154 | title: string;
155 | subtitle: string;
156 | rightElement?: ReactNode;
157 | }
158 |
159 | interface SharedNotificationSettingsProps {
160 | title?: string;
161 | subtitle?: string;
162 | }
163 |
164 | interface SelectedCourseProps {
165 | course: Course;
166 | handleEnrollNow: (courseId: string) => void;
167 | }
168 |
169 | interface ToolbarProps {
170 | onSearch: (search: string) => void;
171 | onCategoryChange: (category: string) => void;
172 | }
173 |
174 | interface ChapterModalProps {
175 | isOpen: boolean;
176 | onClose: () => void;
177 | sectionIndex: number | null;
178 | chapterIndex: number | null;
179 | sections: Section[];
180 | setSections: React.Dispatch>;
181 | courseId: string;
182 | }
183 |
184 | interface SectionModalProps {
185 | isOpen: boolean;
186 | onClose: () => void;
187 | sectionIndex: number | null;
188 | sections: Section[];
189 | setSections: React.Dispatch>;
190 | }
191 |
192 | interface DroppableComponentProps {
193 | sections: Section[];
194 | setSections: (sections: Section[]) => void;
195 | handleEditSection: (index: number) => void;
196 | handleDeleteSection: (index: number) => void;
197 | handleAddChapter: (sectionIndex: number) => void;
198 | handleEditChapter: (sectionIndex: number, chapterIndex: number) => void;
199 | handleDeleteChapter: (sectionIndex: number, chapterIndex: number) => void;
200 | }
201 |
202 | interface CourseFormData {
203 | courseTitle: string;
204 | courseDescription: string;
205 | courseCategory: string;
206 | coursePrice: string;
207 | courseStatus: boolean;
208 | }
209 | }
210 |
211 | export {};
212 |
--------------------------------------------------------------------------------
/client/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | theme: {
11 | extend: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | colors: {
20 | border: "hsl(var(--border))",
21 | input: "hsl(var(--input))",
22 | ring: "hsl(var(--ring))",
23 | background: "hsl(var(--background))",
24 | foreground: "hsl(var(--foreground))",
25 | destructive: {
26 | DEFAULT: "hsl(var(--destructive))",
27 | foreground: "hsl(var(--destructive-foreground))",
28 | },
29 | muted: {
30 | DEFAULT: "hsl(var(--muted))",
31 | foreground: "hsl(var(--muted-foreground))",
32 | },
33 | accent: {
34 | DEFAULT: "hsl(var(--accent))",
35 | foreground: "hsl(var(--accent-foreground))",
36 | },
37 | popover: {
38 | DEFAULT: "hsl(var(--popover))",
39 | foreground: "hsl(var(--popover-foreground))",
40 | },
41 | card: {
42 | DEFAULT: "hsl(var(--card))",
43 | foreground: "hsl(var(--card-foreground))",
44 | },
45 | sidebar: {
46 | DEFAULT: "hsl(var(--sidebar-background))",
47 | foreground: "hsl(var(--sidebar-foreground))",
48 | primary: "hsl(var(--sidebar-primary))",
49 | "primary-foreground": "hsl(var(--sidebar-primary-foreground))",
50 | accent: "hsl(var(--sidebar-accent))",
51 | "accent-foreground": "hsl(var(--sidebar-accent-foreground))",
52 | border: "hsl(var(--sidebar-border))",
53 | ring: "hsl(var(--sidebar-ring))",
54 | },
55 | customgreys: {
56 | primarybg: "#1B1C22",
57 | secondarybg: "#25262F",
58 | darkGrey: "#17181D",
59 | darkerGrey: "#3d3d3d",
60 | dirtyGrey: "#6e6e6e",
61 | },
62 | primary: {
63 | "50": "#fdfdff",
64 | "100": "#f7f7ff",
65 | "200": "#ececff",
66 | "300": "#ddddfe",
67 | "400": "#cacafe",
68 | "500": "#b3b3fd",
69 | "600": "#9898fd",
70 | "700": "#7878fc",
71 | "750": "#5a5be6",
72 | "800": "#0404be",
73 | "900": "#020255",
74 | "950": "#010132",
75 | DEFAULT: "hsl(var(--primary))",
76 | foreground: "hsl(var(--primary-foreground))",
77 | },
78 | secondary: {
79 | "50": "#fcfefe",
80 | "100": "#f3fbfa",
81 | "200": "#e5f7f4",
82 | "300": "#d0f1ec",
83 | "400": "#b6e9e1",
84 | "500": "#96dfd4",
85 | "600": "#70d3c4",
86 | "700": "#44c5b2",
87 | "800": "#227064",
88 | "900": "#123933",
89 | "950": "#0c2723",
90 | DEFAULT: "hsl(var(--secondary))",
91 | foreground: "hsl(var(--secondary-foreground))",
92 | },
93 | white: {
94 | "50": "#d2d2d2",
95 | "100": "#ffffff",
96 | },
97 | tertiary: {
98 | "50": "#E9B306",
99 | },
100 | chart: {
101 | "1": "hsl(var(--chart-1))",
102 | "2": "hsl(var(--chart-2))",
103 | "3": "hsl(var(--chart-3))",
104 | "4": "hsl(var(--chart-4))",
105 | "5": "hsl(var(--chart-5))",
106 | },
107 | },
108 | borderRadius: {
109 | lg: "var(--radius)",
110 | md: "calc(var(--radius) - 2px)",
111 | sm: "calc(var(--radius) - 4px)",
112 | },
113 | keyframes: {
114 | "accordion-down": {
115 | from: {
116 | height: "0",
117 | },
118 | to: {
119 | height: "var(--radix-accordion-content-height)",
120 | },
121 | },
122 | "accordion-up": {
123 | from: {
124 | height: "var(--radix-accordion-content-height)",
125 | },
126 | to: {
127 | height: "0",
128 | },
129 | },
130 | },
131 | animation: {
132 | "accordion-down": "accordion-down 0.2s ease-out",
133 | "accordion-up": "accordion-up 0.2s ease-out",
134 | },
135 | fontFamily: {
136 | sans: ["var(--font-dm-sans)"],
137 | },
138 | fontSize: {
139 | xs: ["0.75rem", { lineHeight: "1rem" }],
140 | sm: ["0.875rem", { lineHeight: "1.25rem" }],
141 | md: ["1rem", { lineHeight: "1.5rem" }],
142 | lg: ["1.125rem", { lineHeight: "1.75rem" }],
143 | xl: ["1.25rem", { lineHeight: "1.75rem" }],
144 | "2xl": ["1.5rem", { lineHeight: "2rem" }],
145 | },
146 | },
147 | },
148 | plugins: [require("tailwindcss-animate"), "prettier-plugin-tailwindcss"],
149 | } satisfies Config;
150 |
151 | export default config;
152 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/server/.dockerignore:
--------------------------------------------------------------------------------
1 | Dockerfile
2 | .dockerignore
3 | node_modules/
4 | npm-debug.log*
5 | README.md
6 | .git/
7 | .gitignore
8 | *.md
9 | dist/
--------------------------------------------------------------------------------
/server/.env.example:
--------------------------------------------------------------------------------
1 | PORT=8001
2 | NODE_ENV=development
3 |
4 | AWS_REGION=YOUR-AWS-REGION
5 | S3_BUCKET_NAME=YOUR-S3-BUCKET-NAME
6 | CLOUDFRONT_DOMAIN=YOUR-CLOUDFRONT-DOMAIN
7 |
8 | STRIPE_SECRET_KEY=YOUR-STRIPE-SECRET-KEY
9 |
10 | CLERK_PUBLISHABLE_KEY=YOUR-CLERK-PUBLISHABLE-KEY
11 | CLERK_SECRET_KEY=YOUR-CLERK-SECRET-KEY
12 |
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use the AWS Lambda Node.js 20 base image
2 | FROM public.ecr.aws/lambda/nodejs:20 AS build
3 |
4 | # Set the working directory
5 | WORKDIR /app
6 |
7 | # Copy package.json and package-lock.json
8 | COPY package*.json ./
9 |
10 | # Install dependencies (including dev dependencies for building)
11 | RUN npm install
12 |
13 | # Copy the rest of your application's source code
14 | COPY . .
15 |
16 | # Build TypeScript files
17 | RUN npm run build
18 |
19 | # Remove dev dependencies
20 | RUN npm prune --production
21 |
22 | # Use a second stage to prepare the production image
23 | FROM public.ecr.aws/lambda/nodejs:20
24 |
25 | # Set the working directory
26 | WORKDIR ${LAMBDA_TASK_ROOT}
27 |
28 | # Copy built JavaScript files and node_modules from the build stage
29 | COPY --from=build /app/dist ${LAMBDA_TASK_ROOT}
30 | COPY --from=build /app/node_modules ${LAMBDA_TASK_ROOT}/node_modules
31 |
32 | # Copy package.json (optional)
33 | COPY --from=build /app/package*.json ${LAMBDA_TASK_ROOT}
34 |
35 | # Set environment variables (adjust as needed)
36 | ENV NODE_ENV=production
37 |
38 | # Command to start the Lambda function
39 | CMD ["index.handler"]
40 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "build": "rimraf dist && npx tsc && cpx \"src/seed/data/**/*\" dist/seed/data",
7 | "start": "npm run build && node dist/index.js",
8 | "dev": "npm run build && concurrently \"npx tsc -w\" \"nodemon --exec ts-node src/index.ts\"",
9 | "seed": "ts-node src/seed/seedDynamodb.ts"
10 | },
11 | "keywords": [],
12 | "author": "",
13 | "license": "ISC",
14 | "description": "",
15 | "dependencies": {
16 | "@aws-sdk/client-dynamodb": "^3.687.0",
17 | "@clerk/express": "^1.3.11",
18 | "@types/multer": "^1.4.12",
19 | "aws-sdk": "^2.1692.0",
20 | "body-parser": "^1.20.3",
21 | "cors": "^2.8.5",
22 | "dotenv": "^16.4.5",
23 | "dynamoose": "^4.0.2",
24 | "express": "^4.21.1",
25 | "helmet": "^8.0.0",
26 | "morgan": "^1.10.0",
27 | "multer": "^1.4.5-lts.1",
28 | "pluralize": "^8.0.0",
29 | "serverless-http": "^3.2.0",
30 | "stripe": "^17.3.1",
31 | "uuid": "^11.0.3"
32 | },
33 | "devDependencies": {
34 | "@types/cors": "^2.8.17",
35 | "@types/express": "^5.0.0",
36 | "@types/morgan": "^1.9.9",
37 | "@types/node": "^22.9.0",
38 | "@types/pluralize": "^0.0.33",
39 | "@types/uuid": "^10.0.0",
40 | "concurrently": "^9.1.0",
41 | "cpx": "^1.5.0",
42 | "nodemon": "^3.1.7",
43 | "rimraf": "^6.0.1",
44 | "ts-node": "^10.9.2",
45 | "typescript": "^5.6.3"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/server/src/controllers/courseController.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from "express";
2 | import Course from "../models/courseModel";
3 | import AWS from "aws-sdk";
4 | import { v4 as uuidv4 } from "uuid";
5 | import { getAuth } from "@clerk/express";
6 |
7 | const s3 = new AWS.S3();
8 |
9 | export const listCourses = async (
10 | req: Request,
11 | res: Response
12 | ): Promise => {
13 | const { category } = req.query;
14 | try {
15 | const courses =
16 | category && category !== "all"
17 | ? await Course.scan("category").eq(category).exec()
18 | : await Course.scan().exec();
19 | res.json({ message: "Courses retrieved successfully", data: courses });
20 | } catch (error) {
21 | res.status(500).json({ message: "Error retrieving courses", error });
22 | }
23 | };
24 |
25 | export const getCourse = async (req: Request, res: Response): Promise => {
26 | const { courseId } = req.params;
27 | try {
28 | const course = await Course.get(courseId);
29 | if (!course) {
30 | res.status(404).json({ message: "Course not found" });
31 | return;
32 | }
33 |
34 | res.json({ message: "Course retrieved successfully", data: course });
35 | } catch (error) {
36 | res.status(500).json({ message: "Error retrieving course", error });
37 | }
38 | };
39 |
40 | export const createCourse = async (
41 | req: Request,
42 | res: Response
43 | ): Promise => {
44 | try {
45 | const { teacherId, teacherName } = req.body;
46 |
47 | if (!teacherId || !teacherName) {
48 | res.status(400).json({ message: "Teacher Id and name are required" });
49 | return;
50 | }
51 |
52 | const newCourse = new Course({
53 | courseId: uuidv4(),
54 | teacherId,
55 | teacherName,
56 | title: "Untitled Course",
57 | description: "",
58 | category: "Uncategorized",
59 | image: "",
60 | price: 0,
61 | level: "Beginner",
62 | status: "Draft",
63 | sections: [],
64 | enrollments: [],
65 | });
66 | await newCourse.save();
67 |
68 | res.json({ message: "Course created successfully", data: newCourse });
69 | } catch (error) {
70 | res.status(500).json({ message: "Error creating course", error });
71 | }
72 | };
73 |
74 | export const updateCourse = async (
75 | req: Request,
76 | res: Response
77 | ): Promise => {
78 | const { courseId } = req.params;
79 | const updateData = { ...req.body };
80 | const { userId } = getAuth(req);
81 |
82 | try {
83 | const course = await Course.get(courseId);
84 | if (!course) {
85 | res.status(404).json({ message: "Course not found" });
86 | return;
87 | }
88 |
89 | if (course.teacherId !== userId) {
90 | res
91 | .status(403)
92 | .json({ message: "Not authorized to update this course " });
93 | return;
94 | }
95 |
96 | if (updateData.price) {
97 | const price = parseInt(updateData.price);
98 | if (isNaN(price)) {
99 | res.status(400).json({
100 | message: "Invalid price format",
101 | error: "Price must be a valid number",
102 | });
103 | return;
104 | }
105 | updateData.price = price * 100;
106 | }
107 |
108 | if (updateData.sections) {
109 | const sectionsData =
110 | typeof updateData.sections === "string"
111 | ? JSON.parse(updateData.sections)
112 | : updateData.sections;
113 |
114 | updateData.sections = sectionsData.map((section: any) => ({
115 | ...section,
116 | sectionId: section.sectionId || uuidv4(),
117 | chapters: section.chapters.map((chapter: any) => ({
118 | ...chapter,
119 | chapterId: chapter.chapterId || uuidv4(),
120 | })),
121 | }));
122 | }
123 |
124 | Object.assign(course, updateData);
125 | await course.save();
126 |
127 | res.json({ message: "Course updated successfully", data: course });
128 | } catch (error) {
129 | res.status(500).json({ message: "Error updating course", error });
130 | }
131 | };
132 |
133 | export const deleteCourse = async (
134 | req: Request,
135 | res: Response
136 | ): Promise => {
137 | const { courseId } = req.params;
138 | const { userId } = getAuth(req);
139 |
140 | try {
141 | const course = await Course.get(courseId);
142 | if (!course) {
143 | res.status(404).json({ message: "Course not found" });
144 | return;
145 | }
146 |
147 | if (course.teacherId !== userId) {
148 | res
149 | .status(403)
150 | .json({ message: "Not authorized to delete this course " });
151 | return;
152 | }
153 |
154 | await Course.delete(courseId);
155 |
156 | res.json({ message: "Course deleted successfully", data: course });
157 | } catch (error) {
158 | res.status(500).json({ message: "Error deleting course", error });
159 | }
160 | };
161 |
162 | export const getUploadVideoUrl = async (
163 | req: Request,
164 | res: Response
165 | ): Promise => {
166 | const { fileName, fileType } = req.body;
167 |
168 | if (!fileName || !fileType) {
169 | res.status(400).json({ message: "File name and type are required" });
170 | return;
171 | }
172 |
173 | try {
174 | const uniqueId = uuidv4();
175 | const s3Key = `videos/${uniqueId}/${fileName}`;
176 |
177 | const s3Params = {
178 | Bucket: process.env.S3_BUCKET_NAME || "",
179 | Key: s3Key,
180 | Expires: 60,
181 | ContentType: fileType,
182 | };
183 |
184 | const uploadUrl = s3.getSignedUrl("putObject", s3Params);
185 | const videoUrl = `${process.env.CLOUDFRONT_DOMAIN}/videos/${uniqueId}/${fileName}`;
186 |
187 | res.json({
188 | message: "Upload URL generated successfully",
189 | data: { uploadUrl, videoUrl },
190 | });
191 | } catch (error) {
192 | res.status(500).json({ message: "Error generating upload URL", error });
193 | }
194 | };
195 |
--------------------------------------------------------------------------------
/server/src/controllers/transactionController.ts:
--------------------------------------------------------------------------------
1 | import Stripe from "stripe";
2 | import dotenv from "dotenv";
3 | import { Request, Response } from "express";
4 | import Course from "../models/courseModel";
5 | import Transaction from "../models/transactionModel";
6 | import UserCourseProgress from "../models/userCourseProgressModel";
7 |
8 | dotenv.config();
9 |
10 | if (!process.env.STRIPE_SECRET_KEY) {
11 | throw new Error(
12 | "STRIPE_SECRET_KEY os required but was not found in env variables"
13 | );
14 | }
15 |
16 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
17 |
18 | export const listTransactions = async (
19 | req: Request,
20 | res: Response
21 | ): Promise => {
22 | const { userId } = req.query;
23 |
24 | try {
25 | const transactions = userId
26 | ? await Transaction.query("userId").eq(userId).exec()
27 | : await Transaction.scan().exec();
28 |
29 | res.json({
30 | message: "Transactions retrieved successfully",
31 | data: transactions,
32 | });
33 | } catch (error) {
34 | res.status(500).json({ message: "Error retrieving transactions", error });
35 | }
36 | };
37 |
38 | export const createStripePaymentIntent = async (
39 | req: Request,
40 | res: Response
41 | ): Promise => {
42 | let { amount } = req.body;
43 |
44 | if (!amount || amount <= 0) {
45 | amount = 50;
46 | }
47 |
48 | try {
49 | const paymentIntent = await stripe.paymentIntents.create({
50 | amount,
51 | currency: "usd",
52 | automatic_payment_methods: {
53 | enabled: true,
54 | allow_redirects: "never",
55 | },
56 | });
57 |
58 | res.json({
59 | message: "",
60 | data: {
61 | clientSecret: paymentIntent.client_secret,
62 | },
63 | });
64 | } catch (error) {
65 | res
66 | .status(500)
67 | .json({ message: "Error creating stripe payment intent", error });
68 | }
69 | };
70 |
71 | export const createTransaction = async (
72 | req: Request,
73 | res: Response
74 | ): Promise => {
75 | const { userId, courseId, transactionId, amount, paymentProvider } = req.body;
76 |
77 | try {
78 | // 1. get course info
79 | const course = await Course.get(courseId);
80 |
81 | // 2. create transaction record
82 | const newTransaction = new Transaction({
83 | dateTime: new Date().toISOString(),
84 | userId,
85 | courseId,
86 | transactionId,
87 | amount,
88 | paymentProvider,
89 | });
90 | await newTransaction.save();
91 |
92 | // 3. create initial course progress
93 | const initialProgress = new UserCourseProgress({
94 | userId,
95 | courseId,
96 | enrollmentDate: new Date().toISOString(),
97 | overallProgress: 0,
98 | sections: course.sections.map((section: any) => ({
99 | sectionId: section.sectionId,
100 | chapters: section.chapters.map((chapter: any) => ({
101 | chapterId: chapter.chapterId,
102 | completed: false,
103 | })),
104 | })),
105 | lastAccessedTimestamp: new Date().toISOString(),
106 | });
107 | await initialProgress.save();
108 |
109 | // 4. add enrollment to relevant course
110 | await Course.update(
111 | { courseId },
112 | {
113 | $ADD: {
114 | enrollments: [{ userId }],
115 | },
116 | }
117 | );
118 |
119 | res.json({
120 | message: "Purchased Course successfully",
121 | data: {
122 | transaction: newTransaction,
123 | courseProgress: initialProgress,
124 | },
125 | });
126 | } catch (error) {
127 | res
128 | .status(500)
129 | .json({ message: "Error creating transaction and enrollment", error });
130 | }
131 | };
132 |
--------------------------------------------------------------------------------
/server/src/controllers/userClerkController.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from "express";
2 | import { clerkClient } from "../index";
3 |
4 | export const updateUser = async (
5 | req: Request,
6 | res: Response
7 | ): Promise => {
8 | const { userId } = req.params;
9 | const userData = req.body;
10 | try {
11 | const user = await clerkClient.users.updateUserMetadata(userId, {
12 | publicMetadata: {
13 | userType: userData.publicMetadata.userType,
14 | settings: userData.publicMetadata.settings,
15 | },
16 | });
17 |
18 | res.json({ message: "User updated successfully", data: user });
19 | } catch (error) {
20 | res.status(500).json({ message: "Error updating user", error });
21 | }
22 | };
23 |
--------------------------------------------------------------------------------
/server/src/controllers/userCourseProgressController.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from "express";
2 | import { getAuth } from "@clerk/express";
3 | import UserCourseProgress from "../models/userCourseProgressModel";
4 | import Course from "../models/courseModel";
5 | import { calculateOverallProgress } from "../utils/utils";
6 | import { mergeSections } from "../utils/utils";
7 |
8 | export const getUserEnrolledCourses = async (
9 | req: Request,
10 | res: Response
11 | ): Promise => {
12 | const { userId } = req.params;
13 | const auth = getAuth(req);
14 |
15 | if (!auth || auth.userId !== userId) {
16 | res.status(403).json({ message: "Access denied" });
17 | return;
18 | }
19 |
20 | try {
21 | const enrolledCourses = await UserCourseProgress.query("userId")
22 | .eq(userId)
23 | .exec();
24 | const courseIds = enrolledCourses.map((item: any) => item.courseId);
25 | const courses = await Course.batchGet(courseIds);
26 | res.json({
27 | message: "Enrolled courses retrieved successfully",
28 | data: courses,
29 | });
30 | } catch (error) {
31 | res
32 | .status(500)
33 | .json({ message: "Error retrieving enrolled courses", error });
34 | }
35 | };
36 |
37 | export const getUserCourseProgress = async (
38 | req: Request,
39 | res: Response
40 | ): Promise => {
41 | const { userId, courseId } = req.params;
42 |
43 | try {
44 | const progress = await UserCourseProgress.get({ userId, courseId });
45 | if (!progress) {
46 | res
47 | .status(404)
48 | .json({ message: "Course progress not found for this user" });
49 | return;
50 | }
51 | res.json({
52 | message: "Course progress retrieved successfully",
53 | data: progress,
54 | });
55 | } catch (error) {
56 | res
57 | .status(500)
58 | .json({ message: "Error retrieving user course progress", error });
59 | }
60 | };
61 |
62 | export const updateUserCourseProgress = async (
63 | req: Request,
64 | res: Response
65 | ): Promise => {
66 | const { userId, courseId } = req.params;
67 | const progressData = req.body;
68 |
69 | try {
70 | let progress = await UserCourseProgress.get({ userId, courseId });
71 |
72 | if (!progress) {
73 | // If no progress exists, create initial progress
74 | progress = new UserCourseProgress({
75 | userId,
76 | courseId,
77 | enrollmentDate: new Date().toISOString(),
78 | overallProgress: 0,
79 | sections: progressData.sections || [],
80 | lastAccessedTimestamp: new Date().toISOString(),
81 | });
82 | } else {
83 | // Merge existing progress with new progress data
84 | progress.sections = mergeSections(
85 | progress.sections,
86 | progressData.sections || []
87 | );
88 | progress.lastAccessedTimestamp = new Date().toISOString();
89 | progress.overallProgress = calculateOverallProgress(progress.sections);
90 | }
91 |
92 | await progress.save();
93 |
94 | res.json({
95 | message: "",
96 | data: progress,
97 | });
98 | } catch (error) {
99 | console.error("Error updating progress:", error);
100 | res.status(500).json({
101 | message: "Error updating user course progress",
102 | error,
103 | });
104 | }
105 | };
106 |
--------------------------------------------------------------------------------
/server/src/index.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import dotenv from "dotenv";
3 | import bodyParser from "body-parser";
4 | import cors from "cors";
5 | import helmet from "helmet";
6 | import morgan from "morgan";
7 | import * as dynamoose from "dynamoose";
8 | import serverless from "serverless-http";
9 | import seed from "./seed/seedDynamodb";
10 | import {
11 | clerkMiddleware,
12 | createClerkClient,
13 | requireAuth,
14 | } from "@clerk/express";
15 | /* ROUTE IMPORTS */
16 | import courseRoutes from "./routes/courseRoutes";
17 | import userClerkRoutes from "./routes/userClerkRoutes";
18 | import transactionRoutes from "./routes/transactionRoutes";
19 | import userCourseProgressRoutes from "./routes/userCourseProgressRoutes";
20 |
21 | /* CONFIGURATIONS */
22 | dotenv.config();
23 | const isProduction = process.env.NODE_ENV === "production";
24 | if (!isProduction) {
25 | dynamoose.aws.ddb.local();
26 | }
27 |
28 | export const clerkClient = createClerkClient({
29 | secretKey: process.env.CLERK_SECRET_KEY,
30 | });
31 |
32 | const app = express();
33 | app.use(express.json());
34 | app.use(helmet());
35 | app.use(helmet.crossOriginResourcePolicy({ policy: "cross-origin" }));
36 | app.use(morgan("common"));
37 | app.use(bodyParser.json());
38 | app.use(bodyParser.urlencoded({ extended: false }));
39 | app.use(cors());
40 | app.use(clerkMiddleware());
41 |
42 | /* ROUTES */
43 | app.get("/", (req, res) => {
44 | res.send("Hello World");
45 | });
46 |
47 | app.use("/courses", courseRoutes);
48 | app.use("/users/clerk", requireAuth(), userClerkRoutes);
49 | app.use("/transactions", requireAuth(), transactionRoutes);
50 | app.use("/users/course-progress", requireAuth(), userCourseProgressRoutes);
51 |
52 | /* SERVER */
53 | const port = process.env.PORT || 3000;
54 | if (!isProduction) {
55 | app.listen(port, () => {
56 | console.log(`Server running on port ${port}`);
57 | });
58 | }
59 |
60 | // aws production environment
61 | const serverlessApp = serverless(app);
62 | export const handler = async (event: any, context: any) => {
63 | if (event.action === "seed") {
64 | await seed();
65 | return {
66 | statusCode: 200,
67 | body: JSON.stringify({ message: "Data seeded successfully" }),
68 | };
69 | } else {
70 | return serverlessApp(event, context);
71 | }
72 | };
73 |
--------------------------------------------------------------------------------
/server/src/models/courseModel.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from "dynamoose";
2 |
3 | const commentSchema = new Schema({
4 | commentId: {
5 | type: String,
6 | required: true,
7 | },
8 | userId: {
9 | type: String,
10 | required: true,
11 | },
12 | text: {
13 | type: String,
14 | required: true,
15 | },
16 | timestamp: {
17 | type: String,
18 | required: true,
19 | },
20 | });
21 |
22 | const chapterSchema = new Schema({
23 | chapterId: {
24 | type: String,
25 | required: true,
26 | },
27 | type: {
28 | type: String,
29 | enum: ["Text", "Quiz", "Video"],
30 | required: true,
31 | },
32 | title: {
33 | type: String,
34 | required: true,
35 | },
36 | content: {
37 | type: String,
38 | required: true,
39 | },
40 | comments: {
41 | type: Array,
42 | schema: [commentSchema],
43 | },
44 | video: {
45 | type: String,
46 | },
47 | });
48 |
49 | const sectionSchema = new Schema({
50 | sectionId: {
51 | type: String,
52 | required: true,
53 | },
54 | sectionTitle: {
55 | type: String,
56 | required: true,
57 | },
58 | sectionDescription: {
59 | type: String,
60 | },
61 | chapters: {
62 | type: Array,
63 | schema: [chapterSchema],
64 | },
65 | });
66 |
67 | const courseSchema = new Schema(
68 | {
69 | courseId: {
70 | type: String,
71 | hashKey: true,
72 | required: true,
73 | },
74 | teacherId: {
75 | type: String,
76 | required: true,
77 | },
78 | teacherName: {
79 | type: String,
80 | required: true,
81 | },
82 | title: {
83 | type: String,
84 | required: true,
85 | },
86 | description: {
87 | type: String,
88 | },
89 | category: {
90 | type: String,
91 | required: true,
92 | },
93 | image: {
94 | type: String,
95 | },
96 | price: {
97 | type: Number,
98 | },
99 | level: {
100 | type: String,
101 | required: true,
102 | enum: ["Beginner", "Intermediate", "Advanced"],
103 | },
104 | status: {
105 | type: String,
106 | required: true,
107 | enum: ["Draft", "Published"],
108 | },
109 | sections: {
110 | type: Array,
111 | schema: [sectionSchema],
112 | },
113 | enrollments: {
114 | type: Array,
115 | schema: [
116 | new Schema({
117 | userId: {
118 | type: String,
119 | required: true,
120 | },
121 | }),
122 | ],
123 | },
124 | },
125 | {
126 | timestamps: true,
127 | }
128 | );
129 |
130 | const Course = model("Course", courseSchema);
131 | export default Course;
132 |
--------------------------------------------------------------------------------
/server/src/models/transactionModel.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from "dynamoose";
2 |
3 | const transactionSchema = new Schema(
4 | {
5 | userId: {
6 | type: String,
7 | hashKey: true,
8 | required: true,
9 | },
10 | transactionId: {
11 | type: String,
12 | rangeKey: true,
13 | required: true,
14 | },
15 | dateTime: {
16 | type: String,
17 | required: true,
18 | },
19 | courseId: {
20 | type: String,
21 | required: true,
22 | index: {
23 | name: "CourseTransactionsIndex",
24 | type: "global",
25 | },
26 | },
27 | paymentProvider: {
28 | type: String,
29 | enum: ["stripe"],
30 | required: true,
31 | },
32 | amount: Number,
33 | },
34 | {
35 | saveUnknown: true,
36 | timestamps: true,
37 | }
38 | );
39 |
40 | const Transaction = model("Transaction", transactionSchema);
41 | export default Transaction;
42 |
--------------------------------------------------------------------------------
/server/src/models/userCourseProgressModel.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from "dynamoose";
2 |
3 | const chapterProgressSchema = new Schema({
4 | chapterId: {
5 | type: String,
6 | required: true,
7 | },
8 | completed: {
9 | type: Boolean,
10 | required: true,
11 | },
12 | });
13 |
14 | const sectionProgressSchema = new Schema({
15 | sectionId: {
16 | type: String,
17 | required: true,
18 | },
19 | chapters: {
20 | type: Array,
21 | schema: [chapterProgressSchema],
22 | },
23 | });
24 |
25 | const userCourseProgressSchema = new Schema(
26 | {
27 | userId: {
28 | type: String,
29 | hashKey: true,
30 | required: true,
31 | },
32 | courseId: {
33 | type: String,
34 | rangeKey: true,
35 | required: true,
36 | },
37 | enrollmentDate: {
38 | type: String,
39 | required: true,
40 | },
41 | overallProgress: {
42 | type: Number,
43 | required: true,
44 | },
45 | sections: {
46 | type: Array,
47 | schema: [sectionProgressSchema],
48 | },
49 | lastAccessedTimestamp: {
50 | type: String,
51 | required: true,
52 | },
53 | },
54 | {
55 | timestamps: true,
56 | }
57 | );
58 |
59 | const UserCourseProgress = model(
60 | "UserCourseProgress",
61 | userCourseProgressSchema
62 | );
63 | export default UserCourseProgress;
64 |
--------------------------------------------------------------------------------
/server/src/routes/courseRoutes.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import multer from "multer";
3 | import {
4 | createCourse,
5 | deleteCourse,
6 | getCourse,
7 | listCourses,
8 | updateCourse,
9 | getUploadVideoUrl,
10 | } from "../controllers/courseController";
11 | import { requireAuth } from "@clerk/express";
12 |
13 | const router = express.Router();
14 | const upload = multer({ storage: multer.memoryStorage() });
15 |
16 | router.get("/", listCourses);
17 | router.post("/", requireAuth(), createCourse);
18 |
19 | router.get("/:courseId", getCourse);
20 | router.put("/:courseId", requireAuth(), upload.single("image"), updateCourse);
21 | router.delete("/:courseId", requireAuth(), deleteCourse);
22 |
23 | router.post(
24 | "/:courseId/sections/:sectionId/chapters/:chapterId/get-upload-url",
25 | requireAuth(),
26 | getUploadVideoUrl
27 | );
28 |
29 | export default router;
30 |
--------------------------------------------------------------------------------
/server/src/routes/transactionRoutes.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import {
3 | createStripePaymentIntent,
4 | createTransaction,
5 | listTransactions,
6 | } from "../controllers/transactionController";
7 |
8 | const router = express.Router();
9 |
10 | router.get("/", listTransactions);
11 | router.post("/", createTransaction);
12 | router.post("/stripe/payment-intent", createStripePaymentIntent);
13 |
14 | export default router;
15 |
--------------------------------------------------------------------------------
/server/src/routes/userClerkRoutes.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import { updateUser } from "../controllers/userClerkController";
3 |
4 | const router = express.Router();
5 |
6 | router.put("/:userId", updateUser);
7 |
8 | export default router;
9 |
--------------------------------------------------------------------------------
/server/src/routes/userCourseProgressRoutes.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import {
3 | getUserCourseProgress,
4 | getUserEnrolledCourses,
5 | updateUserCourseProgress,
6 | } from "../controllers/userCourseProgressController";
7 |
8 | const router = express.Router();
9 |
10 | router.get("/:userId/enrolled-courses", getUserEnrolledCourses);
11 | router.get("/:userId/courses/:courseId", getUserCourseProgress);
12 | router.put("/:userId/courses/:courseId", updateUserCourseProgress);
13 |
14 | export default router;
15 |
--------------------------------------------------------------------------------
/server/src/seed/data/transactions.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "userId": "user_2ntu96pUCljUV2T9W0AThzjacQB",
4 | "transactionId": "pi_1a2b3c4d5e6f7g8h9i0j1k2l",
5 | "dateTime": "2024-03-01T10:30:00Z",
6 | "courseId": "3a9f3d6c-c391-4b1c-9c3d-6c3f3d6c3f3d",
7 | "paymentProvider": "stripe",
8 | "amount": 4999
9 | },
10 | {
11 | "userId": "user_2ntu96pUCljUV2T9W0AThzjacQB",
12 | "transactionId": "pi_2b3c4d5e6f7g8h9i0j1k2l3m",
13 | "dateTime": "2024-03-15T14:45:00Z",
14 | "courseId": "8b4f7d9c-4b1c-4b1c-8b4f-7d9c8b4f7d9c",
15 | "paymentProvider": "stripe",
16 | "amount": 9999
17 | },
18 | {
19 | "userId": "user_5vBn23WsLkMp7Jh4Gt8FxYcRz",
20 | "transactionId": "pi_3c4d5e6f7g8h9i0j1k2l3m4n",
21 | "dateTime": "2024-03-20T09:00:00Z",
22 | "courseId": "c5d6e7f8-g9h0-i1j2-k3l4-m5n6o7p8q9r0",
23 | "paymentProvider": "stripe",
24 | "amount": 7999
25 | },
26 | {
27 | "userId": "user_6tHm89QwNpKj3Fx5Vy2RdLcBs",
28 | "transactionId": "pi_4d5e6f7g8h9i0j1k2l3m4n5o",
29 | "dateTime": "2024-03-20T11:15:00Z",
30 | "courseId": "d4e5f6g7-h8i9-j0k1-l2m3-n4o5p6q7r8s9",
31 | "paymentProvider": "stripe",
32 | "amount": 8999
33 | },
34 | {
35 | "userId": "user_9xWp45MnKjL8vRt2Hs6BqDcEy",
36 | "transactionId": "pi_5e6f7g8h9i0j1k2l3m4n5o6p",
37 | "dateTime": "2024-03-25T08:15:00Z",
38 | "courseId": "3a9f3d6c-c391-4b1c-9c3d-6c3f3d6c3f3d",
39 | "paymentProvider": "stripe",
40 | "amount": 4999
41 | },
42 | {
43 | "userId": "user_6tHm89QwNpKj3Fx5Vy2RdLcBs",
44 | "transactionId": "pi_6f7g8h9i0j1k2l3m4n5o6p7q",
45 | "dateTime": "2024-03-27T13:20:00Z",
46 | "courseId": "e5f6g7h8-i9j0-k1l2-m3n4-o5p6q7r8s9t0",
47 | "paymentProvider": "stripe",
48 | "amount": 12999
49 | },
50 | {
51 | "userId": "user_9xWp45MnKjL8vRt2Hs6BqDcEy",
52 | "transactionId": "pi_7g8h9i0j1k2l3m4n5o6p7q8r",
53 | "dateTime": "2024-03-28T15:30:00Z",
54 | "courseId": "c5d6e7f8-g9h0-i1j2-k3l4-m5n6o7p8q9r0",
55 | "paymentProvider": "stripe",
56 | "amount": 7999
57 | },
58 | {
59 | "userId": "user_6tHm89QwNpKj3Fx5Vy2RdLcBs",
60 | "transactionId": "pi_8h9i0j1k2l3m4n5o6p7q8r9s",
61 | "dateTime": "2024-03-29T09:45:00Z",
62 | "courseId": "8b4f7d9c-4b1c-4b1c-8b4f-7d9c8b4f7d9c",
63 | "paymentProvider": "stripe",
64 | "amount": 9999
65 | }
66 | ]
67 |
--------------------------------------------------------------------------------
/server/src/seed/data/userCourseProgress.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "userId": "user_2ntu96pUCljUV2T9W0AThzjacQB",
4 | "courseId": "3a9f3d6c-c391-4b1c-9c3d-6c3f3d6c3f3d",
5 | "enrollmentDate": "2023-03-01T09:00:00Z",
6 | "overallProgress": 0.75,
7 | "sections": [
8 | {
9 | "sectionId": "2f9d1e8b-5a3c-4b7f-9e6d-8c2a1f0b3d5e",
10 | "chapters": [
11 | {
12 | "chapterId": "a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6",
13 | "completed": true
14 | },
15 | {
16 | "chapterId": "b2c3d4e5-f6g7-h8i9-j0k1-l2m3n4o5p6q7",
17 | "completed": false
18 | }
19 | ]
20 | }
21 | ],
22 | "lastAccessedTimestamp": "2023-03-10T14:30:00Z"
23 | },
24 | {
25 | "userId": "user_2ntu96pUCljUV2T9W0AThzjacQB",
26 | "courseId": "8b4f7d9c-4b1c-4b1c-8b4f-7d9c8b4f7d9c",
27 | "enrollmentDate": "2023-03-15T10:00:00Z",
28 | "overallProgress": 0.25,
29 | "sections": [
30 | {
31 | "sectionId": "1a7b3c5d-9e2f-4g6h-8i0j-2k4l6m8n0p1q",
32 | "chapters": [
33 | {
34 | "chapterId": "c3d4e5f6-g7h8-i9j0-k1l2-m3n4o5p6q7r8",
35 | "completed": true
36 | },
37 | {
38 | "chapterId": "d4e5f6g7-h8i9-j0k1-l2m3-n4o5p6q7r8s9",
39 | "completed": false
40 | }
41 | ]
42 | }
43 | ],
44 | "lastAccessedTimestamp": "2023-03-20T16:45:00Z"
45 | },
46 | {
47 | "userId": "user_3rTg67LmZnXc4Vb8Wd0JyUhEq",
48 | "courseId": "c5d6e7f8-g9h0-i1j2-k3l4-m5n6o7p8q9r0",
49 | "enrollmentDate": "2023-04-01T11:30:00Z",
50 | "overallProgress": 0.5,
51 | "sections": [
52 | {
53 | "sectionId": "3e5f7g9h-1i3j-5k7l-9m1n-3o5p7q9r1s3t",
54 | "chapters": [
55 | {
56 | "chapterId": "e5f6g7h8-i9j0-k1l2-m3n4-o5p6q7r8s9t0",
57 | "completed": true
58 | },
59 | {
60 | "chapterId": "f6g7h8i9-j0k1-l2m3-n4o5-p6q7r8s9t0u1",
61 | "completed": true
62 | },
63 | {
64 | "chapterId": "g7h8i9j0-k1l2-m3n4-o5p6-q7r8s9t0u1v2",
65 | "completed": false
66 | }
67 | ]
68 | }
69 | ],
70 | "lastAccessedTimestamp": "2023-04-10T09:15:00Z"
71 | },
72 | {
73 | "userId": "user_5vBn23WsLkMp7Jh4Gt8FxYcRz",
74 | "courseId": "d4e5f6g7-h8i9-j0k1-l2m3-n4o5p6q7r8s9",
75 | "enrollmentDate": "2023-04-05T14:00:00Z",
76 | "overallProgress": 0.1,
77 | "sections": [
78 | {
79 | "sectionId": "4u6v8w0x-2y4z-6a8b-0c2d-4e6f8g0h2i4j",
80 | "chapters": [
81 | {
82 | "chapterId": "h8i9j0k1-l2m3-n4o5-p6q7-r8s9t0u1v2w3",
83 | "completed": true
84 | },
85 | {
86 | "chapterId": "i9j0k1l2-m3n4-o5p6-q7r8-s9t0u1v2w3x4",
87 | "completed": false
88 | }
89 | ]
90 | }
91 | ],
92 | "lastAccessedTimestamp": "2023-04-15T11:30:00Z"
93 | },
94 | {
95 | "userId": "user_8qPk34ZxCvBn1Mh6Jt9WsYdAe",
96 | "courseId": "e5f6g7h8-i9j0-k1l2-m3n4-o5p6q7r8s9t0",
97 | "enrollmentDate": "2023-04-10T09:30:00Z",
98 | "overallProgress": 0.8,
99 | "sections": [
100 | {
101 | "sectionId": "5k7l9m1n-3o5p-7q9r-1s3t-5u7v9w1x3y5z",
102 | "chapters": [
103 | {
104 | "chapterId": "j0k1l2m3-n4o5-p6q7-r8s9-t0u1v2w3x4y5",
105 | "completed": true
106 | },
107 | {
108 | "chapterId": "k1l2m3n4-o5p6-q7r8-s9t0-u1v2w3x4y5z6",
109 | "completed": true
110 | }
111 | ]
112 | }
113 | ],
114 | "lastAccessedTimestamp": "2023-04-20T15:45:00Z"
115 | }
116 | ]
117 |
--------------------------------------------------------------------------------
/server/src/seed/seedDynamodb.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DynamoDBClient,
3 | DeleteTableCommand,
4 | ListTablesCommand,
5 | } from "@aws-sdk/client-dynamodb";
6 | import fs from "fs";
7 | import path from "path";
8 | import dynamoose from "dynamoose";
9 | import pluralize from "pluralize";
10 | import Transaction from "../models/transactionModel";
11 | import Course from "../models/courseModel";
12 | import UserCourseProgress from "../models/userCourseProgressModel";
13 | import dotenv from "dotenv";
14 |
15 | dotenv.config();
16 | let client: DynamoDBClient;
17 |
18 | /* DynamoDB Configuration */
19 | const isProduction = process.env.NODE_ENV === "production";
20 |
21 | if (!isProduction) {
22 | dynamoose.aws.ddb.local();
23 | client = new DynamoDBClient({
24 | endpoint: "http://localhost:8000",
25 | region: "us-east-2",
26 | credentials: {
27 | accessKeyId: "dummyKey123",
28 | secretAccessKey: "dummyKey123",
29 | },
30 | });
31 | } else {
32 | client = new DynamoDBClient({
33 | region: process.env.AWS_REGION || "us-east-2",
34 | });
35 | }
36 |
37 | /* DynamoDB Suppress Tag Warnings */
38 | const originalWarn = console.warn.bind(console);
39 | console.warn = (message, ...args) => {
40 | if (
41 | !message.includes("Tagging is not currently supported in DynamoDB Local")
42 | ) {
43 | originalWarn(message, ...args);
44 | }
45 | };
46 |
47 | async function createTables() {
48 | const models = [Transaction, UserCourseProgress, Course];
49 |
50 | for (const model of models) {
51 | const tableName = model.name;
52 | const table = new dynamoose.Table(tableName, [model], {
53 | create: true,
54 | update: true,
55 | waitForActive: true,
56 | throughput: { read: 5, write: 5 },
57 | });
58 |
59 | try {
60 | await new Promise((resolve) => setTimeout(resolve, 2000));
61 | await table.initialize();
62 | console.log(`Table created and initialized: ${tableName}`);
63 | } catch (error: any) {
64 | console.error(
65 | `Error creating table ${tableName}:`,
66 | error.message,
67 | error.stack
68 | );
69 | }
70 | }
71 | }
72 |
73 | async function seedData(tableName: string, filePath: string) {
74 | const data: { [key: string]: any }[] = JSON.parse(
75 | fs.readFileSync(filePath, "utf8")
76 | );
77 |
78 | const formattedTableName = pluralize.singular(
79 | tableName.charAt(0).toUpperCase() + tableName.slice(1)
80 | );
81 |
82 | console.log(`Seeding data to table: ${formattedTableName}`);
83 |
84 | for (const item of data) {
85 | try {
86 | await dynamoose.model(formattedTableName).create(item);
87 | } catch (err) {
88 | console.error(
89 | `Unable to add item to ${formattedTableName}. Error:`,
90 | JSON.stringify(err, null, 2)
91 | );
92 | }
93 | }
94 |
95 | console.log(
96 | "\x1b[32m%s\x1b[0m",
97 | `Successfully seeded data to table: ${formattedTableName}`
98 | );
99 | }
100 |
101 | async function deleteTable(baseTableName: string) {
102 | let deleteCommand = new DeleteTableCommand({ TableName: baseTableName });
103 | try {
104 | await client.send(deleteCommand);
105 | console.log(`Table deleted: ${baseTableName}`);
106 | } catch (err: any) {
107 | if (err.name === "ResourceNotFoundException") {
108 | console.log(`Table does not exist: ${baseTableName}`);
109 | } else {
110 | console.error(`Error deleting table ${baseTableName}:`, err);
111 | }
112 | }
113 | }
114 |
115 | async function deleteAllTables() {
116 | const listTablesCommand = new ListTablesCommand({});
117 | const { TableNames } = await client.send(listTablesCommand);
118 |
119 | if (TableNames && TableNames.length > 0) {
120 | for (const tableName of TableNames) {
121 | await deleteTable(tableName);
122 | await new Promise((resolve) => setTimeout(resolve, 800));
123 | }
124 | }
125 | }
126 |
127 | export default async function seed() {
128 | await deleteAllTables();
129 | await new Promise((resolve) => setTimeout(resolve, 1000));
130 | await createTables();
131 |
132 | const seedDataPath = path.join(__dirname, "./data");
133 | const files = fs
134 | .readdirSync(seedDataPath)
135 | .filter((file) => file.endsWith(".json"));
136 |
137 | for (const file of files) {
138 | const tableName = path.basename(file, ".json");
139 | const filePath = path.join(seedDataPath, file);
140 | await seedData(tableName, filePath);
141 | }
142 | }
143 |
144 | if (require.main === module) {
145 | seed().catch((error) => {
146 | console.error("Failed to run seed script:", error);
147 | });
148 | }
149 |
--------------------------------------------------------------------------------
/server/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 |
3 | export const updateCourseVideoInfo = (
4 | course: any,
5 | sectionId: string,
6 | chapterId: string,
7 | videoUrl: string
8 | ) => {
9 | const section = course.sections?.find((s: any) => s.sectionId === sectionId);
10 | if (!section) {
11 | throw new Error(`Section not found: ${sectionId}`);
12 | }
13 |
14 | const chapter = section.chapters?.find((c: any) => c.chapterId === chapterId);
15 | if (!chapter) {
16 | throw new Error(`Chapter not found: ${chapterId}`);
17 | }
18 |
19 | chapter.video = videoUrl;
20 | chapter.type = "Video";
21 | };
22 |
23 | export const validateUploadedFiles = (files: any) => {
24 | const allowedExtensions = [".mp4", ".m3u8", ".mpd", ".ts", ".m4s"];
25 | for (const file of files) {
26 | const ext = path.extname(file.originalname).toLowerCase();
27 | if (!allowedExtensions.includes(ext)) {
28 | throw new Error(`Unsupported file type: ${ext}`);
29 | }
30 | }
31 | };
32 |
33 | export const getContentType = (filename: string) => {
34 | const ext = path.extname(filename).toLowerCase();
35 | switch (ext) {
36 | case ".mp4":
37 | return "video/mp4";
38 | case ".m3u8":
39 | return "application/vnd.apple.mpegurl";
40 | case ".mpd":
41 | return "application/dash+xml";
42 | case ".ts":
43 | return "video/MP2T";
44 | case ".m4s":
45 | return "video/iso.segment";
46 | default:
47 | return "application/octet-stream";
48 | }
49 | };
50 |
51 | // Preserved HLS/DASH upload logic for future use
52 | export const handleAdvancedVideoUpload = async (
53 | s3: any,
54 | files: any,
55 | uniqueId: string,
56 | bucketName: string
57 | ) => {
58 | const isHLSOrDASH = files.some(
59 | (file: any) =>
60 | file.originalname.endsWith(".m3u8") || file.originalname.endsWith(".mpd")
61 | );
62 |
63 | if (isHLSOrDASH) {
64 | // Handle HLS/MPEG-DASH Upload
65 | const uploadPromises = files.map((file: any) => {
66 | const s3Key = `videos/${uniqueId}/${file.originalname}`;
67 | return s3
68 | .upload({
69 | Bucket: bucketName,
70 | Key: s3Key,
71 | Body: file.buffer,
72 | ContentType: getContentType(file.originalname),
73 | })
74 | .promise();
75 | });
76 | await Promise.all(uploadPromises);
77 |
78 | // Determine manifest file URL
79 | const manifestFile = files.find(
80 | (file: any) =>
81 | file.originalname.endsWith(".m3u8") ||
82 | file.originalname.endsWith(".mpd")
83 | );
84 | const manifestFileName = manifestFile?.originalname || "";
85 | const videoType = manifestFileName.endsWith(".m3u8") ? "hls" : "dash";
86 |
87 | return {
88 | videoUrl: `${process.env.CLOUDFRONT_DOMAIN}/videos/${uniqueId}/${manifestFileName}`,
89 | videoType,
90 | };
91 | }
92 |
93 | return null; // Return null if not HLS/DASH to handle regular upload
94 | };
95 |
96 | export const mergeSections = (
97 | existingSections: any[],
98 | newSections: any[]
99 | ): any[] => {
100 | const existingSectionsMap = new Map();
101 | for (const existingSection of existingSections) {
102 | existingSectionsMap.set(existingSection.sectionId, existingSection);
103 | }
104 |
105 | for (const newSection of newSections) {
106 | const section = existingSectionsMap.get(newSection.sectionId);
107 | if (!section) {
108 | // Add new section
109 | existingSectionsMap.set(newSection.sectionId, newSection);
110 | } else {
111 | // Merge chapters within the existing section
112 | section.chapters = mergeChapters(section.chapters, newSection.chapters);
113 | existingSectionsMap.set(newSection.sectionId, section);
114 | }
115 | }
116 |
117 | return Array.from(existingSectionsMap.values());
118 | };
119 |
120 | export const mergeChapters = (
121 | existingChapters: any[],
122 | newChapters: any[]
123 | ): any[] => {
124 | const existingChaptersMap = new Map();
125 | for (const existingChapter of existingChapters) {
126 | existingChaptersMap.set(existingChapter.chapterId, existingChapter);
127 | }
128 |
129 | for (const newChapter of newChapters) {
130 | existingChaptersMap.set(newChapter.chapterId, {
131 | ...(existingChaptersMap.get(newChapter.chapterId) || {}),
132 | ...newChapter,
133 | });
134 | }
135 |
136 | return Array.from(existingChaptersMap.values());
137 | };
138 |
139 | export const calculateOverallProgress = (sections: any[]): number => {
140 | const totalChapters = sections.reduce(
141 | (acc: number, section: any) => acc + section.chapters.length,
142 | 0
143 | );
144 |
145 | const completedChapters = sections.reduce(
146 | (acc: number, section: any) =>
147 | acc + section.chapters.filter((chapter: any) => chapter.completed).length,
148 | 0
149 | );
150 |
151 | return totalChapters > 0 ? (completedChapters / totalChapters) * 100 : 0;
152 | };
153 |
--------------------------------------------------------------------------------
|