From 7882b0493af551b30a3334cd762a1b4a72698e52 Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:52:50 +0300 Subject: [PATCH 01/31] refactor: remove unused quiz and recommendation service functions --- apps/api/src/services/quizzes.ts | 3 --- apps/api/src/services/recommendations.ts | 3 --- 2 files changed, 6 deletions(-) delete mode 100644 apps/api/src/services/quizzes.ts delete mode 100644 apps/api/src/services/recommendations.ts diff --git a/apps/api/src/services/quizzes.ts b/apps/api/src/services/quizzes.ts deleted file mode 100644 index 2288cc0..0000000 --- a/apps/api/src/services/quizzes.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const generateNewQuiz = () => { - console.log("Generating a new quiz..."); -}; diff --git a/apps/api/src/services/recommendations.ts b/apps/api/src/services/recommendations.ts deleted file mode 100644 index 63a8bd7..0000000 --- a/apps/api/src/services/recommendations.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const getRecommendations = () => { - console.log("Getting recommendations..."); -}; From 0fb3a0b0179beb1b0552bbb78e273418d9b17ecd Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:52:58 +0300 Subject: [PATCH 02/31] fix(pnpm-lock): update dependency versions and add axios --- pnpm-lock.yaml | 71 +++++++++++++++++++++++++++----------------------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d1a243..86d32e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: dependencies: '@adminjs/express': specifier: ^6.1.1 - version: 6.1.1(adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8))(express-formidable@1.2.0)(express-session@1.18.2)(express@4.21.2)(tslib@2.8.1) + version: 6.1.1(adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8))(express-formidable@1.2.0)(express-session@1.18.2)(express@4.21.2)(tslib@2.8.1) '@prisma/client': specifier: 6.11.1 version: 6.11.1(prisma@6.12.0(typescript@5.8.2))(typescript@5.8.2) @@ -34,7 +34,10 @@ importers: version: 2.1.13(@tiptap/core@2.1.13(@tiptap/pm@2.1.13))(@tiptap/pm@2.1.13) adminjs: specifier: ^7.8.17 - version: 7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8) + version: 7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8) + axios: + specifier: ^1.11.0 + version: 1.11.0 better-auth: specifier: ^1.2.12 version: 1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -173,7 +176,7 @@ importers: version: 9.32.0 '@tailwindcss/vite': specifier: ^4.1.5 - version: 4.1.11(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) + version: 4.1.11(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 @@ -194,7 +197,7 @@ importers: version: 19.1.6(@types/react@19.1.8) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) + version: 4.7.0(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) @@ -233,16 +236,16 @@ importers: version: 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.7.3) vite: specifier: ^6.3.1 - version: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + version: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) vite-plugin-svgr: specifier: ^4.3.0 - version: 4.3.0(rollup@4.40.2)(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) + version: 4.3.0(rollup@4.40.2)(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) + version: 5.1.4(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) vitest: specifier: ^3.1.2 - version: 3.2.4(@types/node@22.16.5)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3) + version: 3.2.4(@types/node@22.16.5)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) packages: @@ -5688,9 +5691,9 @@ snapshots: - react-is - supports-color - '@adminjs/express@6.1.1(adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8))(express-formidable@1.2.0)(express-session@1.18.2)(express@4.21.2)(tslib@2.8.1)': + '@adminjs/express@6.1.1(adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8))(express-formidable@1.2.0)(express-session@1.18.2)(express@4.21.2)(tslib@2.8.1)': dependencies: - adminjs: 7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8) + adminjs: 7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8) express: 4.21.2 express-formidable: 1.2.0 express-session: 1.18.2 @@ -6963,7 +6966,7 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@hello-pangea/dnd@16.6.0(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@hello-pangea/dnd@16.6.0(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.2 css-box-model: 1.2.1 @@ -6971,7 +6974,7 @@ snapshots: raf-schd: 4.0.3 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-redux: 8.1.3(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) + react-redux: 8.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) redux: 4.2.1 use-memo-one: 1.1.3(react@18.3.1) transitivePeerDependencies: @@ -7736,12 +7739,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.11 '@tailwindcss/oxide-win32-x64-msvc': 4.1.11 - '@tailwindcss/vite@4.1.11(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3))': + '@tailwindcss/vite@4.1.11(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2))': dependencies: '@tailwindcss/node': 4.1.11 '@tailwindcss/oxide': 4.1.11 tailwindcss: 4.1.11 - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) '@tanstack/query-core@5.83.0': {} @@ -8282,7 +8285,7 @@ snapshots: '@typescript-eslint/types': 8.38.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react@4.7.0(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3))': + '@vitejs/plugin-react@4.7.0(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0) @@ -8290,7 +8293,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) transitivePeerDependencies: - supports-color @@ -8302,13 +8305,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3))': + '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -8349,7 +8352,7 @@ snapshots: acorn@8.15.0: {} - adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8): + adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8): dependencies: '@adminjs/design-system': 4.1.1(@babel/core@7.28.0)(@types/react@19.1.8)(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1) '@babel/core': 7.28.0 @@ -8360,7 +8363,7 @@ snapshots: '@babel/preset-react': 7.27.1(@babel/core@7.28.0) '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0) '@babel/register': 7.27.1(@babel/core@7.28.0) - '@hello-pangea/dnd': 16.6.0(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@hello-pangea/dnd': 16.6.0(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@redux-devtools/extension': 3.3.0(redux@4.2.1) '@rollup/plugin-babel': 6.0.4(@babel/core@7.28.0)(@types/babel__core@7.20.5)(rollup@4.40.2) '@rollup/plugin-commonjs': 25.0.8(rollup@4.40.2) @@ -8383,7 +8386,7 @@ snapshots: react-feather: 2.0.10(react@18.3.1) react-i18next: 12.3.1(i18next@22.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-is: 18.3.1 - react-redux: 8.1.3(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) + react-redux: 8.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) react-router: 6.30.1(react@18.3.1) react-router-dom: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) redux: 4.2.1 @@ -10601,7 +10604,7 @@ snapshots: react-fast-compare: 3.2.2 warning: 4.0.3 - react-redux@8.1.3(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1): + react-redux@8.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1): dependencies: '@babel/runtime': 7.28.2 '@types/hoist-non-react-statics': 3.3.7(@types/react@19.1.8) @@ -10612,6 +10615,7 @@ snapshots: use-sync-external-store: 1.5.0(react@18.3.1) optionalDependencies: '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) react-dom: 18.3.1(react@18.3.1) redux: 4.2.1 @@ -11424,13 +11428,13 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3): + vite-node@3.2.4(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2): dependencies: cac: 6.7.14 debug: 4.4.1(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) transitivePeerDependencies: - '@types/node' - jiti @@ -11445,29 +11449,29 @@ snapshots: - tsx - yaml - vite-plugin-svgr@4.3.0(rollup@4.40.2)(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)): + vite-plugin-svgr@4.3.0(rollup@4.40.2)(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)): dependencies: '@rollup/pluginutils': 5.1.4(rollup@4.40.2) '@svgr/core': 8.1.0(typescript@5.7.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.7.3)) - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) transitivePeerDependencies: - rollup - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)): + vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)): dependencies: debug: 4.4.0 globrex: 0.1.2 tsconfck: 3.1.5(typescript@5.7.3) optionalDependencies: - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) transitivePeerDependencies: - supports-color - typescript - vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3): + vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2): dependencies: esbuild: 0.25.4 fdir: 6.4.6(picomatch@4.0.3) @@ -11481,12 +11485,13 @@ snapshots: jiti: 2.5.1 lightningcss: 1.30.1 tsx: 4.20.3 + yaml: 1.10.2 - vitest@3.2.4(@types/node@22.16.5)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3): + vitest@3.2.4(@types/node@22.16.5)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) + '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -11504,8 +11509,8 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) - vite-node: 3.2.4(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) + vite-node: 3.2.4(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.16.5 From 684fdc7342dfb1908b967d673e214c2cad8cf96c Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:53:02 +0300 Subject: [PATCH 03/31] fix(package.json): ensure axios dependency is included --- apps/api/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/package.json b/apps/api/package.json index 8aeb33f..9319bb7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,6 +19,7 @@ "@prisma/client": "6.11.1", "@tiptap/extension-horizontal-rule": "2.1.13", "adminjs": "^7.8.17", + "axios": "^1.11.0", "better-auth": "^1.2.12", "cors": "^2.8.5", "dotenv": "^17.2.1", From 6ba0573ef174138aa71fc6e43ea1f98c888eb8c9 Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:53:24 +0300 Subject: [PATCH 04/31] feat(prisma): add difficulty and attempt tracking to Topic model; set default values for DailyQuiz --- apps/api/prisma/schema.prisma | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 9f7ec5e..b83210a 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -66,6 +66,10 @@ model Topic { userCompletions UserCompletion[] questions Question[] userPerformances UserTopicPerformance[] + + difficulty QuestionDifficulty @default(easy) + attempted Int @default(0) + solved Int @default(0) } model UserCompletion { @@ -134,6 +138,9 @@ model DailyQuiz { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt submittedAt DateTime? + totalQuestions Int @default(10) + + @@unique([userId, createdAt]) } model User { @@ -226,3 +233,4 @@ model Jwks { @@map("jwks") } + From 6b4575f0c141ce34d455adc7e42266269813eeed Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:53:33 +0300 Subject: [PATCH 05/31] feat(migration): add totalQuestions to DailyQuiz and attempted, difficulty, solved to Topic; create unique index on DailyQuiz --- .../migration.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 apps/api/prisma/migrations/20250813194643_adding_unique_id_quiz/migration.sql diff --git a/apps/api/prisma/migrations/20250813194643_adding_unique_id_quiz/migration.sql b/apps/api/prisma/migrations/20250813194643_adding_unique_id_quiz/migration.sql new file mode 100644 index 0000000..4c2d110 --- /dev/null +++ b/apps/api/prisma/migrations/20250813194643_adding_unique_id_quiz/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - A unique constraint covering the columns `[userId,createdAt]` on the table `DailyQuiz` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "DailyQuiz" ADD COLUMN "totalQuestions" INTEGER NOT NULL DEFAULT 10; + +-- AlterTable +ALTER TABLE "Topic" ADD COLUMN "attempted" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "difficulty" "QuestionDifficulty" NOT NULL DEFAULT 'easy', +ADD COLUMN "solved" INTEGER NOT NULL DEFAULT 0; + +-- CreateIndex +CREATE UNIQUE INDEX "DailyQuiz_userId_createdAt_key" ON "DailyQuiz"("userId", "createdAt"); From 2c972bbc92031cb2523b3826417e4c169ada899c Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:53:38 +0300 Subject: [PATCH 06/31] chore: update app.ts for improved error handling and middleware configuration --- apps/api/src/app.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index be0dc45..6344954 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,13 +3,44 @@ import express from "express"; import { auth } from "./lib/auth.js"; import { toNodeHandler } from "better-auth/node"; import { admin, adminRouter } from "./lib/admin.js"; +import { quizzesRouter } from "./routes/quizzes.js"; const app = express(); +// Basic middleware +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + app.disable("x-powered-by"); app.all("/api/auth/*", toNodeHandler(auth)); app.use(admin.options.rootPath, adminRouter); console.log(`AdminJS is running under ${admin.options.rootPath}`); +// API routes +app.use("/api/quizzes", quizzesRouter); + +// Simple health route +app.get("/api/health", (_req: express.Request, res: express.Response): void => { + res.json({ status: "ok" }); +}); + +// Error handler +// eslint-disable-next-line @typescript-eslint/no-unused-vars +app.use((( + err: any, + _req: express.Request, + res: express.Response, + _next: express.NextFunction +) => { + console.error("[Error]", err); + if (err?.message === "Unauthorized") { + res.status(401).json({ status: "fail", message: "Unauthorized" }); + return; + } + res + .status(500) + .json({ status: "error", message: err?.message || "Server error" }); +}) as express.ErrorRequestHandler); + export default app; From 34d20013f7ebe8695ec333f7944e0f27cc664622 Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:53:45 +0300 Subject: [PATCH 07/31] feat(calendar): implement getQuizSubmissionCalendar to track user quiz submissions by date --- .../api/src/controller/calender.controller.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 apps/api/src/controller/calender.controller.ts diff --git a/apps/api/src/controller/calender.controller.ts b/apps/api/src/controller/calender.controller.ts new file mode 100644 index 0000000..d7b2d15 --- /dev/null +++ b/apps/api/src/controller/calender.controller.ts @@ -0,0 +1,84 @@ +import { Request, Response } from "express"; +import type { Session, User } from "better-auth"; +import { prisma } from "../lib/prisma.js"; + +declare global { + // Ensure session typing just like other controllers + namespace Express { + interface Request { + session?: Session; + user?: User; + } + } +} + +interface ApiResponse { + status: string; + message?: string; + data?: T; +} +const send = (res: Response, code: number, body: ApiResponse) => + res.status(code).json(body); + +// Utility to get month boundaries in local time (00:00:00.000 inclusive to next month start exclusive) +const monthRange = (year: number, month0: number) => { + const start = new Date(year, month0, 1, 0, 0, 0, 0); + const end = new Date(year, month0 + 1, 1, 0, 0, 0, 0); + return { start, end }; +}; + +/** + * Returns an array of booleans (index 0 = day 1) for the requested month indicating + * which days the user submitted a quiz (DailyQuiz.submittedAt not null). + * Query params: year=YYYY, month=1-12 (defaults to current year/month if omitted) + */ +export const getQuizSubmissionCalendar = async ( + req: Request, + res: Response +): Promise => { + if (!req.session) { + send(res, 401, { status: "fail", message: "Unauthorized" }); + return; + } + const userId = req.session.userId; + + // Parse month/year with fallbacks + const now = new Date(); + const year = Number(req.query.year) || now.getFullYear(); + const monthParam = Number(req.query.month); // 1-12 + const month0 = + monthParam && monthParam >= 1 && monthParam <= 12 ? + monthParam - 1 + : now.getMonth(); + + try { + const { start, end } = monthRange(year, month0); + const daysInMonth = new Date(year, month0 + 1, 0).getDate(); + const days: boolean[] = Array(daysInMonth).fill(false); + + const submissions = await prisma.dailyQuiz.findMany({ + where: { + userId, + submittedAt: { not: null, gte: start, lt: end }, + }, + select: { submittedAt: true }, + }); + + for (const s of submissions) { + if (!s.submittedAt) continue; + const day = s.submittedAt.getDate(); // 1-based + if (day >= 1 && day <= daysInMonth) days[day - 1] = true; + } + + send(res, 200, { + status: "success", + data: { year, month: month0 + 1, days }, + }); + } catch (err) { + console.error("[getQuizSubmissionCalendar] error", err); + send(res, 500, { + status: "error", + message: "Failed to fetch calendar", + }); + } +}; From 37f6029be1717bd91208ccacae02d55b617a6c3d Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:53:52 +0300 Subject: [PATCH 08/31] feat(quiz): implement getQuiz and submitQuiz endpoints for quiz management --- apps/api/src/controller/quiz.controller.ts | 130 +++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 apps/api/src/controller/quiz.controller.ts diff --git a/apps/api/src/controller/quiz.controller.ts b/apps/api/src/controller/quiz.controller.ts new file mode 100644 index 0000000..df66ce1 --- /dev/null +++ b/apps/api/src/controller/quiz.controller.ts @@ -0,0 +1,130 @@ +import { Request, Response } from "express"; +import type { Session, User } from "better-auth"; +import { fetchAiRecommendation } from "../services/ai.service.js"; +import { buildUserQuizData } from "../services/quiz-data.service.js"; +import { fetchQuestionsByRecommendation } from "../services/question.service.js"; +import { + saveOrUpdateDailyQuiz, + findTodayDailyQuiz, +} from "../services/daily-quiz.service.js"; +import { + gradeAnswers, + SubmittedAnswerInput, +} from "../services/quiz-submission.service.js"; +import { updateDailyQuizScoreByUserToday } from "../services/daily-quiz.service.js"; + +declare global { + namespace Express { + interface Request { + session?: Session; + user?: User; + } + } +} + +// Utility helpers ------------------------------------------------------------- +const startOfDay = (d: Date) => + new Date(d.getFullYear(), d.getMonth(), d.getDate()); + +interface ApiResponse { + status: string; + message?: string; + data?: T; +} +const send = (res: Response, code: number, body: ApiResponse) => + res.status(code).json(body); + +export const getQuiz = async (req: Request, res: Response): Promise => { + if (!req.session) { + send(res, 401, { status: "fail", message: "Unauthorized" }); + return; + } + const userId = req.session.userId; + const today = new Date(); + + try { + // Fetch existing quiz (by date range) if any + const existingQuiz = await findTodayDailyQuiz(userId, today); + const totalQuestions = existingQuiz?.totalQuestions || 10; + + // Build data for AI + const quizData = await buildUserQuizData(userId, totalQuestions); + if (!quizData) { + send(res, 404, { + status: "fail", + message: "User hasn't completed any topics yet", + }); + return; + } + + const aiRecommendation = await fetchAiRecommendation(quizData); + const questions = await fetchQuestionsByRecommendation( + aiRecommendation, + totalQuestions + ); + + // Persist / update quiz record (schema only stores counts currently) + const savedQuiz = await saveOrUpdateDailyQuiz( + userId, + startOfDay(today), + totalQuestions + ); + + send(res, 200, { + status: "success", + data: { quiz: savedQuiz, questions, aiRecommendation }, + }); + } catch (err) { + console.error("[getQuiz] error", err); + send(res, 500, { + status: "error", + message: "Failed to generate quiz", + }); + } +}; + +export const submitQuiz = async ( + req: Request, + res: Response +): Promise => { + if (!req.session) { + send(res, 401, { status: "fail", message: "Unauthorized" }); + return; + } + const userId = req.session.userId; + const today = new Date(); + + try { + const { answers } = req.body as { answers: SubmittedAnswerInput[] }; + if (!Array.isArray(answers) || answers.length === 0) { + send(res, 400, { + status: "fail", + message: "answers array required", + }); + return; + } + + const grading = await gradeAnswers(answers); + await updateDailyQuizScoreByUserToday( + userId, + today, + grading.scorePercentage + ); + + send(res, 200, { + status: "success", + data: { + score: grading.scorePercentage, + correctCount: grading.correctCount, + total: grading.total, + answers: grading.graded, + }, + }); + } catch (err) { + console.error("[submitQuiz] error", err); + send(res, 500, { + status: "error", + message: "Failed to submit quiz", + }); + } +}; From 6dd9535ab9da09f19bddb870edf6a09e897aec17 Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:53:59 +0300 Subject: [PATCH 09/31] feat(quizzes): implement calendar endpoint for quiz submission tracking --- apps/api/src/routes/quizzes.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/api/src/routes/quizzes.ts b/apps/api/src/routes/quizzes.ts index 06dbcac..44c0f5c 100644 --- a/apps/api/src/routes/quizzes.ts +++ b/apps/api/src/routes/quizzes.ts @@ -1,23 +1,29 @@ import { Router } from "express"; import { validate } from "../middlewares/validate.js"; +import { requireAuth } from "../middlewares/auth.js"; import { submitDailyQuizBodySchema } from "../schemas/quizzes.js"; +import { getQuiz, submitQuiz } from "../controller/quiz.controller.js"; +import { getQuizSubmissionCalendar } from "../controller/calender.controller.js"; const router = Router(); -router.get("/monthly-stats", (req, res) => { - res.send(req.url); -}); +// Calendar of submissions (month view) +router.get( + "/calendar", + requireAuth, + // optional validation for query could be added later + getQuizSubmissionCalendar +); -router.get("/daily", (req, res) => { - res.send(req.url); -}); +// Fetch / (re)generate today's quiz for the user +router.get("/daily", requireAuth, getQuiz); +// Submit answers for today's quiz router.post( "/daily", + requireAuth, validate({ body: submitDailyQuizBodySchema }), - (req, res) => { - res.send(req.url); - }, + submitQuiz ); export { router as quizzesRouter }; From 6a576b97e8c8f45a7113a74d0d396f18181839f8 Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:54:05 +0300 Subject: [PATCH 10/31] feat(quizzes): update submitDailyQuizBodySchema to require choiceIndex and remove optional answer field --- apps/api/src/schemas/quizzes.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/api/src/schemas/quizzes.ts b/apps/api/src/schemas/quizzes.ts index 1e6e4e9..226288a 100644 --- a/apps/api/src/schemas/quizzes.ts +++ b/apps/api/src/schemas/quizzes.ts @@ -1,10 +1,12 @@ import z from "zod"; export const submitDailyQuizBodySchema = z.object({ - answers: z.array( - z.object({ - questionId: z.string().uuid(), - answer: z.string().optional(), - }), - ), + answers: z + .array( + z.object({ + questionId: z.string().uuid(), + choiceIndex: z.number().int().min(0), + }) + ) + .min(1), }); From 1eeb191bf470a64764ff8653a63bfcb79de66359 Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:54:09 +0300 Subject: [PATCH 11/31] feat(ai): add fetchAiRecommendation function for AI-based quiz recommendations --- apps/api/src/services/ai.service.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 apps/api/src/services/ai.service.ts diff --git a/apps/api/src/services/ai.service.ts b/apps/api/src/services/ai.service.ts new file mode 100644 index 0000000..12848a9 --- /dev/null +++ b/apps/api/src/services/ai.service.ts @@ -0,0 +1,14 @@ +import axios from "axios"; + +export const fetchAiRecommendation = async (quizData: any) => { + try { + const response = await axios.post( + "http://localhost:5000/api/data", + quizData + ); + return response.data; + } catch (err) { + console.error("AI Recommendation error:", err); + throw new Error("Failed to get AI recommendation"); + } +}; From 04377644af417fa13eb17e99255ee9f1ee507a4a Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:54:14 +0300 Subject: [PATCH 12/31] feat(daily-quiz): implement daily quiz service with CRUD operations --- apps/api/src/services/daily-quiz.service.ts | 67 +++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 apps/api/src/services/daily-quiz.service.ts diff --git a/apps/api/src/services/daily-quiz.service.ts b/apps/api/src/services/daily-quiz.service.ts new file mode 100644 index 0000000..c24acf7 --- /dev/null +++ b/apps/api/src/services/daily-quiz.service.ts @@ -0,0 +1,67 @@ +// Local prisma client +import { prisma } from "../lib/prisma.js"; + +const startOfDay = (d: Date) => + new Date(d.getFullYear(), d.getMonth(), d.getDate()); +const nextDay = (d: Date) => + new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1); + +export const findTodayDailyQuiz = async ( + userId: string, + refDate = new Date() +) => { + return prisma.dailyQuiz.findFirst({ + where: { + userId, + createdAt: { gte: startOfDay(refDate), lt: nextDay(refDate) }, + }, + orderBy: { createdAt: "desc" }, + }); +}; + +export const createDailyQuiz = async ( + userId: string, + totalQuestions: number +) => { + return prisma.dailyQuiz.create({ + data: { userId, totalQuestions, score: 0 }, + }); +}; + +export const saveOrUpdateDailyQuiz = async ( + userId: string, + refDate: Date, + totalQuestions: number +) => { + const existing = await findTodayDailyQuiz(userId, refDate); + if (existing) { + if (existing.totalQuestions !== totalQuestions) { + return prisma.dailyQuiz.update({ + where: { id: existing.id }, + data: { totalQuestions }, + }); + } + return existing; + } + return createDailyQuiz(userId, totalQuestions); +}; + +export const submitDailyQuiz = async (quizId: string, score: number) => { + return prisma.dailyQuiz.update({ + where: { id: quizId }, + data: { score, submittedAt: new Date() }, + }); +}; + +export const updateDailyQuizScoreByUserToday = async ( + userId: string, + refDate: Date, + score: number +) => { + const quiz = await findTodayDailyQuiz(userId, refDate); + if (!quiz) return null; + return prisma.dailyQuiz.update({ + where: { id: quiz.id }, + data: { score, submittedAt: new Date() }, + }); +}; From 1f6b5e2066061130cbc9d4a1644c5fb4bfbd8f44 Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:54:18 +0300 Subject: [PATCH 13/31] feat(questions): add fetchQuestionsByRecommendation function to retrieve questions based on AI recommendations --- apps/api/src/services/question.service.ts | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/api/src/services/question.service.ts diff --git a/apps/api/src/services/question.service.ts b/apps/api/src/services/question.service.ts new file mode 100644 index 0000000..db3f88b --- /dev/null +++ b/apps/api/src/services/question.service.ts @@ -0,0 +1,25 @@ +import { prisma } from "../lib/prisma.js"; + +export const fetchQuestionsByRecommendation = async ( + aiRecommendation: any, + totalQuestions: number +) => { + const levelsToFetch = aiRecommendation.topics.flatMap((topic: any) => + topic.recommendations.map((rec: any) => rec.level) + ); + + return prisma.question.findMany({ + where: { + topics: { + some: { + course: { + level: { + title: { in: levelsToFetch.map((lvl: number) => `Level ${lvl}`) }, + }, + }, + }, + }, + }, + take: totalQuestions, + }); +}; From 81440cda306ac1960ae3d25d4a186bdd01b79141 Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:54:21 +0300 Subject: [PATCH 14/31] feat(quiz-data): implement buildUserQuizData function to aggregate user quiz topics and progress --- apps/api/src/services/quiz-data.service.ts | 97 ++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 apps/api/src/services/quiz-data.service.ts diff --git a/apps/api/src/services/quiz-data.service.ts b/apps/api/src/services/quiz-data.service.ts new file mode 100644 index 0000000..a29e4a1 --- /dev/null +++ b/apps/api/src/services/quiz-data.service.ts @@ -0,0 +1,97 @@ +import { prisma } from "../lib/prisma.js"; + +export const buildUserQuizData = async ( + userId: string, + totalQuestions: number +) => { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + userCompletions: { + include: { + topic: { + include: { + course: { include: { level: true } }, + questions: true, + }, + }, + }, + }, + }, + }); + + const userCompletions = user?.userCompletions; + if (!userCompletions || userCompletions.length === 0) { + return null; + } + + const topicMap = new Map< + string, + { topic: string; available: { level: number; count: number }[] } + >(); + const progressMap = new Map< + string, + { + topic: string; + progressByLevel: { level: number; solved: number; attempted: number }[]; + } + >(); + + for (const completion of userCompletions) { + const topic = completion.topic; + const course = topic.course; + const levelNumber = parseInt(course.level.title.match(/\d+/)?.[0] || "0"); + + // Build userTopics + if (!topicMap.has(topic.title)) { + topicMap.set(topic.title, { + topic: topic.title, + available: [{ level: levelNumber, count: topic.questions.length }], + }); + } else { + const available = topicMap.get(topic.title)!.available; + const levelEntry = available.find((a) => a.level === levelNumber); + if (levelEntry) { + levelEntry.count += topic.questions.length; + } else { + available.push({ level: levelNumber, count: topic.questions.length }); + } + } + + // Build userProgress + if (!progressMap.has(topic.title)) { + progressMap.set(topic.title, { + topic: topic.title, + progressByLevel: [ + { + level: levelNumber, + solved: topic.solved, + attempted: topic.attempted, + }, + ], + }); + } else { + const progressByLevel = progressMap.get(topic.title)!.progressByLevel; + const progressEntry = progressByLevel.find( + (p) => p.level === levelNumber + ); + if (progressEntry) { + progressEntry.solved += topic.solved; + progressEntry.attempted += topic.attempted; + } else { + progressByLevel.push({ + level: levelNumber, + solved: topic.solved, + attempted: topic.attempted, + }); + } + } + } + + return { + userId, + totalQuestions, + userTopics: Array.from(topicMap.values()), + userProgress: Array.from(progressMap.values()), + }; +}; From 88371c7d221d5cf7aaea786a7c18550d6931fca9 Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Wed, 13 Aug 2025 22:54:25 +0300 Subject: [PATCH 15/31] feat(quiz-submission): add gradeAnswers function to evaluate quiz submissions and calculate scores --- .../src/services/quiz-submission.service.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 apps/api/src/services/quiz-submission.service.ts diff --git a/apps/api/src/services/quiz-submission.service.ts b/apps/api/src/services/quiz-submission.service.ts new file mode 100644 index 0000000..fab58a5 --- /dev/null +++ b/apps/api/src/services/quiz-submission.service.ts @@ -0,0 +1,75 @@ +import { prisma } from "../lib/prisma.js"; + +export interface SubmittedAnswerInput { + questionId: string; + choiceIndex: number; +} + +export interface GradedAnswerResult { + questionId: string; + userChoiceIndex: number | null; + correctOptionIndex: number; + isCorrect: boolean; +} + +export interface GradeQuizResult { + graded: GradedAnswerResult[]; + correctCount: number; + total: number; + scorePercentage: number; // 0-100 +} + +/** + * Grades a batch of answers. Missing or invalid questions are ignored but still counted toward total if they existed in input. + */ +export const gradeAnswers = async ( + answers: SubmittedAnswerInput[] +): Promise => { + // Dedupe by questionId (keep last answer provided by user) + const map = new Map(); + for (const a of answers) { + if (a && a.questionId) map.set(a.questionId, a); + } + const uniqueAnswers = Array.from(map.values()); + if (uniqueAnswers.length === 0) { + return { graded: [], correctCount: 0, total: 0, scorePercentage: 0 }; + } + + const questionIds = uniqueAnswers.map((a) => a.questionId); + const questions = await prisma.question.findMany({ + where: { id: { in: questionIds } }, + select: { id: true, correctOptionIndex: true }, + }); + const questionMap = new Map< + string, + { id: string; correctOptionIndex: number } + >( + questions.map((q: { id: string; correctOptionIndex: number }) => [q.id, q]) + ); + + const graded: GradedAnswerResult[] = uniqueAnswers.map((a) => { + const q = questionMap.get(a.questionId); + if (!q) { + return { + questionId: a.questionId, + userChoiceIndex: a.choiceIndex ?? null, + correctOptionIndex: -1, + isCorrect: false, + }; + } + const isCorrect = a.choiceIndex === q.correctOptionIndex; + return { + questionId: q.id, + userChoiceIndex: a.choiceIndex ?? null, + correctOptionIndex: q.correctOptionIndex, + isCorrect, + }; + }); + + const correctCount = graded.filter((g) => g.isCorrect).length; + const total = graded.length; + const scorePercentage = + total === 0 ? 0 : +((correctCount / total) * 100).toFixed(2); + + return { graded, correctCount, total, scorePercentage }; +}; From 08b00d99276b46eda127c192d33ffa742ad8d69b Mon Sep 17 00:00:00 2001 From: ahmed elgaml Date: Sat, 16 Aug 2025 05:25:13 +0300 Subject: [PATCH 16/31] implement the user-topic completion feature and add the essential functions for courses and topics and tracks with server-side validation and aplly authntication middleware --- apps/api/src/app.ts | 3 +- apps/api/src/routes/courses.ts | 67 ++++++++++++++++++++++++++++++++ apps/api/src/routes/index.ts | 17 ++++++++ apps/api/src/routes/topics.ts | 40 +++++++++++++++++++ apps/api/src/routes/tracks.ts | 60 ++++++++++++++++++++++++++++ apps/api/src/schemas/courses.ts | 9 +++++ apps/api/src/schemas/topics.ts | 5 +++ apps/api/src/schemas/tracks.ts | 8 ++++ apps/api/src/services/courses.ts | 61 +++++++++++++++++++++++++++++ apps/api/src/services/topics.ts | 54 +++++++++++++++++++++++++ apps/api/src/services/tracks.ts | 25 ++++++++++++ 11 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/routes/courses.ts create mode 100644 apps/api/src/routes/index.ts create mode 100644 apps/api/src/routes/topics.ts create mode 100644 apps/api/src/routes/tracks.ts create mode 100644 apps/api/src/schemas/courses.ts create mode 100644 apps/api/src/schemas/topics.ts create mode 100644 apps/api/src/schemas/tracks.ts create mode 100644 apps/api/src/services/courses.ts create mode 100644 apps/api/src/services/topics.ts create mode 100644 apps/api/src/services/tracks.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index be0dc45..c79443c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,7 +3,7 @@ import express from "express"; import { auth } from "./lib/auth.js"; import { toNodeHandler } from "better-auth/node"; import { admin, adminRouter } from "./lib/admin.js"; - +import api from "./routes/index.js"; const app = express(); app.disable("x-powered-by"); @@ -11,5 +11,6 @@ app.disable("x-powered-by"); app.all("/api/auth/*", toNodeHandler(auth)); app.use(admin.options.rootPath, adminRouter); console.log(`AdminJS is running under ${admin.options.rootPath}`); +app.use("/api", api); export default app; diff --git a/apps/api/src/routes/courses.ts b/apps/api/src/routes/courses.ts new file mode 100644 index 0000000..2bc4bd6 --- /dev/null +++ b/apps/api/src/routes/courses.ts @@ -0,0 +1,67 @@ +import exprees from "express"; +const router = exprees.Router(); +import { requireAuth } from "../middlewares/auth.js"; +import { validate } from "../middlewares/validate.js"; +import { + getCourseParamsSchema, + getCourseQuerySchema, +} from "../schemas/courses.js"; +import * as Service from "../services/courses.js"; +import * as topicService from "../services/topics.js"; + +router.get( + "/:courseId", + requireAuth, + validate({ params: getCourseParamsSchema }), + async (req, res) => { + const course = await Service.getCourse(req.params.courseId); + + const totalTopics = await Service.getToltalTopics(req.params.courseId); + + const completedTopics = await Service.getCompletedTopics( + req.user!.id, + req.params.courseId, + ); + const persentage = (completedTopics.length / totalTopics.length) * 100; + + res.status(200).json({ + course, + totalTopics, + persentage, + }); + }, +); +router.get( + "/:courseId/topics", + requireAuth, + validate({ params: getCourseParamsSchema, query: getCourseQuerySchema }), + async (req, res) => { + const { completed } = req.query; + + const totalTopics = await topicService.getToltalTopics(req.params.courseId); + + const completedTopics = await topicService.getCompletedTopics( + req.user!.id, + req.params.courseId, + ); + + let filterdTopics; + if (completed === "true") { + filterdTopics = completedTopics; + } else if (completed === "false") { + const completedTopicsIds = completedTopics.map((ele) => ele.topic.id); + const unCompletedTopics = totalTopics.filter( + (ele) => !completedTopicsIds.includes(ele.id), + ); + filterdTopics = unCompletedTopics; + } else { + filterdTopics = totalTopics; + } + + res.status(200).json({ + filterdTopics, + }); + }, +); + +export { router as coursesRouter }; diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts new file mode 100644 index 0000000..a66cad7 --- /dev/null +++ b/apps/api/src/routes/index.ts @@ -0,0 +1,17 @@ +import express from "express"; +const router = express.Router(); +import { tracksRouter } from "./tracks.js"; +import { coursesRouter } from "./courses.js"; +import { topicsRouter } from "./topics.js"; + +router.get("/", (req, res) => { + res.status(200).json({ + message: "hello from api.", + }); +}); + +router.use("/tracks", tracksRouter); +router.use("/courses", coursesRouter); +router.use("/topics", topicsRouter); + +export default router; diff --git a/apps/api/src/routes/topics.ts b/apps/api/src/routes/topics.ts new file mode 100644 index 0000000..d56f2e3 --- /dev/null +++ b/apps/api/src/routes/topics.ts @@ -0,0 +1,40 @@ +import exprees from "express"; +const router = exprees.Router(); +import { validate } from "../middlewares/validate.js"; +import { getTopicParamsSchema } from "../schemas/topics.js"; +import * as Service from "../services/topics.js"; +import { requireAuth } from "../middlewares/auth.js"; + +router.get( + "/:topicId", + validate({ params: getTopicParamsSchema }), + async (req, res) => { + const topic = await Service.getTopic(req.params.topicId); + + res.status(200).json({ + topic, + }); + }, +); +router.post( + "/:topicId/completion", + requireAuth, + validate({ params: getTopicParamsSchema }), + async (req, res) => { + const topic = await Service.completeTopic(req.user!.id, req.params.topicId); + res.status(201).json({ + topic, + }); + }, +); +router.delete( + "/:topicId/completion", + requireAuth, + validate({ params: getTopicParamsSchema }), + async (req, res) => { + await Service.inCompleteTopic(req.user!.id, req.params.topicId); + res.status(204).json({}); + }, +); + +export { router as topicsRouter }; diff --git a/apps/api/src/routes/tracks.ts b/apps/api/src/routes/tracks.ts new file mode 100644 index 0000000..9776dc9 --- /dev/null +++ b/apps/api/src/routes/tracks.ts @@ -0,0 +1,60 @@ +import exprees from "express"; +const router = exprees.Router(); +import { Prisma } from "../generated/prisma/client.js"; +import { validate } from "../middlewares/validate.js"; +import { + getTrackQuerySchema, + getTrackParamsSchema, +} from "../schemas/tracks.js"; +import * as Service from "../services/tracks.js"; +import { requireAuth } from "../middlewares/auth.js"; +import * as courseSrvice from "../services/courses.js"; + +router.get("/", async (req, res) => { + const tracks = await Service.getAllTracks(); + + res.status(200).json({ + length: tracks.length, + data: tracks, + }); +}); +router.get( + "/:trackId", + requireAuth, + validate({ params: getTrackParamsSchema, query: getTrackQuerySchema }), + async (req, res) => { + const { levelId } = req.query as { levelId: string }; + const track = await Service.getTrack(req.params.trackId); + + const courses = await courseSrvice.getCourses( + levelId, + req.params.trackId, + req.user!.id, + ); + const progress = courses.map( + ( + course: Prisma.CourseGetPayload<{ + include: { topics: { select: { userCompletions: true } } }; + }>, + ) => { + const totalTopics = course.topics.length; + const completedTopics = course.topics.filter( + (topic) => topic.userCompletions.length > 0, + ).length; + const percentage = (completedTopics / totalTopics) * 100; + + return { + id: course.id, + title: course.title, + percentage, + }; + }, + ); + res.status(200).json({ + track, + courses: progress, + }); + }, +); + +export { router as tracksRouter }; diff --git a/apps/api/src/schemas/courses.ts b/apps/api/src/schemas/courses.ts new file mode 100644 index 0000000..3571e3c --- /dev/null +++ b/apps/api/src/schemas/courses.ts @@ -0,0 +1,9 @@ +import z from "zod"; + +export const getCourseParamsSchema = z.object({ + courseId: z.string().min(1, "courseId is required"), +}); + +export const getCourseQuerySchema = z.object({ + completed: z.string().optional(), +}); diff --git a/apps/api/src/schemas/topics.ts b/apps/api/src/schemas/topics.ts new file mode 100644 index 0000000..0a34e09 --- /dev/null +++ b/apps/api/src/schemas/topics.ts @@ -0,0 +1,5 @@ +import z from "zod"; + +export const getTopicParamsSchema = z.object({ + topicId: z.string().min(1, "topicId is required"), +}); diff --git a/apps/api/src/schemas/tracks.ts b/apps/api/src/schemas/tracks.ts new file mode 100644 index 0000000..8130adb --- /dev/null +++ b/apps/api/src/schemas/tracks.ts @@ -0,0 +1,8 @@ +import z from "zod"; + +export const getTrackParamsSchema = z.object({ + trackId: z.string().min(1, "trackId is required"), +}); +export const getTrackQuerySchema = z.object({ + levelId: z.string().optional(), +}); diff --git a/apps/api/src/services/courses.ts b/apps/api/src/services/courses.ts new file mode 100644 index 0000000..065b1b0 --- /dev/null +++ b/apps/api/src/services/courses.ts @@ -0,0 +1,61 @@ +import { prisma } from "../lib/prisma.js"; + +export const getCourse = async (courseId: string) => { + return await prisma.course.findUnique({ + where: { + id: courseId, + }, + }); +}; + +export const getToltalTopics = async (courseId: string) => { + return await prisma.topic.findMany({ + where: { + courseId, + }, + select: { + title: true, + }, + orderBy: { + order: "asc", + }, + }); +}; + +export const getCompletedTopics = async (userId: string, courseId: string) => { + return await prisma.userCompletion.findMany({ + where: { + userId, + topic: { + courseId, + }, + }, + }); +}; + +export const getCourses = async ( + levelId: string, + trackId: string, + userId: string, +) => { + return await prisma.course.findMany({ + where: { + trackId, + ...(levelId && { levelId }), + }, + orderBy: { + order: "asc", + }, + include: { + topics: { + select: { + userCompletions: { + where: { + userId, + }, + }, + }, + }, + }, + }); +}; diff --git a/apps/api/src/services/topics.ts b/apps/api/src/services/topics.ts new file mode 100644 index 0000000..c42b5c0 --- /dev/null +++ b/apps/api/src/services/topics.ts @@ -0,0 +1,54 @@ +import { prisma } from "../lib/prisma.js"; + +export const getTopic = async (topicId: string) => { + return prisma.topic.findUnique({ + where: { + id: topicId, + }, + select: { + title: true, + durationInMinutes: true, + content: true, + }, + }); +}; + +export const completeTopic = async (userId: string, topicId: string) => { + return await prisma.userCompletion.create({ + data: { + userId, + topicId, + }, + }); +}; +export const inCompleteTopic = async (userId: string, topicId: string) => { + prisma.userCompletion.delete({ + where: { + userId_topicId: { userId, topicId }, + }, + }); +}; + +export const getToltalTopics = async (courseId: string) => { + return await prisma.topic.findMany({ + where: { + courseId, + }, + orderBy: { + order: "asc", + }, + }); +}; +export const getCompletedTopics = async (userId: string, courseId: string) => { + return await prisma.userCompletion.findMany({ + where: { + userId, + topic: { + courseId, + }, + }, + include: { + topic: true, + }, + }); +}; diff --git a/apps/api/src/services/tracks.ts b/apps/api/src/services/tracks.ts new file mode 100644 index 0000000..2c9c858 --- /dev/null +++ b/apps/api/src/services/tracks.ts @@ -0,0 +1,25 @@ +import { prisma } from "../lib/prisma.js"; + +export const getAllTracks = async () => { + const tracks = await prisma.track.findMany({ + select: { + id: true, + title: true, + }, + }); + return tracks; +}; + +export const getTrack = async (trackId: string) => { + const track = await prisma.track.findUnique({ + where: { + id: trackId, + }, + select: { + id: true, + title: true, + description: true, + }, + }); + return track; +}; From 790354d45ddc32f7f7aaf5c1aa5a815c15262ec1 Mon Sep 17 00:00:00 2001 From: Elshahaby Date: Sat, 16 Aug 2025 19:31:45 +0300 Subject: [PATCH 17/31] feat(api): implement notes feature Adds full CRUD functionality for user notes, including advanced filtering, sorting, and pagination. --- .../migration.sql | 22 +++ apps/api/prisma/schema.prisma | 20 +++ apps/api/src/app.ts | 4 +- apps/api/src/errors/not-found.ts | 12 ++ apps/api/src/routes/notes.ts | 95 +++++++++++ apps/api/src/schemas/notes.ts | 97 +++++++++++ apps/api/src/services/notes.ts | 155 ++++++++++++++++++ 7 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 apps/api/prisma/migrations/20250815080928_add_notes_model/migration.sql create mode 100644 apps/api/src/errors/not-found.ts create mode 100644 apps/api/src/routes/notes.ts create mode 100644 apps/api/src/schemas/notes.ts create mode 100644 apps/api/src/services/notes.ts diff --git a/apps/api/prisma/migrations/20250815080928_add_notes_model/migration.sql b/apps/api/prisma/migrations/20250815080928_add_notes_model/migration.sql new file mode 100644 index 0000000..6d8bc9b --- /dev/null +++ b/apps/api/prisma/migrations/20250815080928_add_notes_model/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "Note" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + "topicId" TEXT NOT NULL, + "courseId" TEXT NOT NULL, + + CONSTRAINT "Note_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Note" ADD CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Note" ADD CONSTRAINT "Note_topicId_fkey" FOREIGN KEY ("topicId") REFERENCES "Topic"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Note" ADD CONSTRAINT "Note_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 9f7ec5e..14c4e60 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -49,6 +49,7 @@ model Course { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt topics Topic[] + notes Note[] } model Topic { @@ -66,6 +67,24 @@ model Topic { userCompletions UserCompletion[] questions Question[] userPerformances UserTopicPerformance[] + notes Note[] +} + +model Note { + id String @id @default(cuid()) + title String + content String @db.Text + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // --- Relations --- + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + topicId String + topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade) + courseId String + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) } model UserCompletion { @@ -158,6 +177,7 @@ model User { trackId String? joinedTrack Track? @relation(fields: [trackId], references: [id], onDelete: Cascade, name: "joinedTrack") userCompletions UserCompletion[] + notes Note[] // Instructors createdTracks Track[] diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index be0dc45..84f9736 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,13 +3,15 @@ import express from "express"; import { auth } from "./lib/auth.js"; import { toNodeHandler } from "better-auth/node"; import { admin, adminRouter } from "./lib/admin.js"; +import { notesRouter } from "./routes/notes.js"; const app = express(); - app.disable("x-powered-by"); app.all("/api/auth/*", toNodeHandler(auth)); app.use(admin.options.rootPath, adminRouter); console.log(`AdminJS is running under ${admin.options.rootPath}`); +app.use(express.json()); +app.use(notesRouter); export default app; diff --git a/apps/api/src/errors/not-found.ts b/apps/api/src/errors/not-found.ts new file mode 100644 index 0000000..e2157bd --- /dev/null +++ b/apps/api/src/errors/not-found.ts @@ -0,0 +1,12 @@ +import BaseError from "./base.js"; + +export class NotFoundError extends BaseError { + constructor(hint?: string) { + super( + "Not Found: Ensure the requested resource exists.", + 404, + undefined, + hint, + ); + } +} diff --git a/apps/api/src/routes/notes.ts b/apps/api/src/routes/notes.ts new file mode 100644 index 0000000..1cad568 --- /dev/null +++ b/apps/api/src/routes/notes.ts @@ -0,0 +1,95 @@ +import { Router } from "express"; +import { validate } from "../middlewares/validate.js"; +import { + createNote, + deleteNote, + getAllFilteredNotes, + getNoteById, + updateNote, +} from "../services/notes.js"; +import { + CreateNoteBodySchema, + GetAllNotesQuerySchema, + noteIdSchema, + topicIdSchema, + UpdateNoteBodySchema, +} from "../schemas/notes.js"; +import { requireAuth } from "../middlewares/auth.js"; + +const router = Router(); + +router.use(requireAuth); + +router.get( + "/api/notes/:noteId", + validate({ params: noteIdSchema }), + async (req, res) => { + const { noteId } = req.params; + const response = await getNoteById(noteId, req.user!.id); + res.status(200).json({ + status: "success", + data: response, + }); + }, +); + +router.get( + "/api/notes", + validate({ query: GetAllNotesQuerySchema }), + async (req, res) => { + const response = await getAllFilteredNotes(req.user!.id, req.query); + res.status(200).json({ + status: "success", + pagination: response.pagination, + data: response.data, + }); + }, +); + +router.post( + "/api/topics/:topicId/notes", + validate({ params: topicIdSchema, body: CreateNoteBodySchema }), + async (req, res) => { + const { topicId } = req.params; + const { title, content } = req.body; + const response = await createNote( + { title, content }, + topicId, + req.user!.id, + ); + res.status(201).json({ + status: "success", + data: response, + }); + }, +); + +router.put( + "/api/notes/:noteId", + validate({ params: noteIdSchema, body: UpdateNoteBodySchema }), + async (req, res) => { + const { noteId } = req.params; + const { title, content } = req.body; + + const response = await updateNote(noteId, req.user!.id, { title, content }); + res.status(200).json({ + status: "success", + data: response, + }); + }, +); + +router.delete( + "/api/notes/:noteId", + validate({ params: noteIdSchema }), + async (req, res) => { + const { noteId } = req.params; + await deleteNote(noteId, req.user!.id); + res.status(204).json({ + status: "success", + message: "Note deleted successfully.", + }); + }, +); + +export { router as notesRouter }; diff --git a/apps/api/src/schemas/notes.ts b/apps/api/src/schemas/notes.ts new file mode 100644 index 0000000..3bbbcf3 --- /dev/null +++ b/apps/api/src/schemas/notes.ts @@ -0,0 +1,97 @@ +import z from "zod"; + +export const CreateNoteBodySchema = z.object({ + title: z + .string() + .trim() + .min(1, { message: "Title is required and must be at least 1 character." }), + content: z + .string() + .trim() + .min(1, { + message: "Content is required and must be at least 1 character.", + }), +}); + +export const NoteSchema = z.object({ + id: z.string().cuid({ message: "id must be a valid CUID." }), + title: z.string().min(1, { message: "Title must be at least 1 character." }), + content: z + .string() + .min(1, { message: "Content must be at least 1 character." }), + topicId: z.string().cuid({ message: "topicId must be a valid CUID." }), + userId: z.string().cuid({ message: "userId must be a valid CUID." }), + courseId: z.string().cuid({ message: "courseId must be a valid CUID." }), + createdAt: z.date({ message: "createdAt must be a valid date." }), + updatedAt: z.date({ message: "updatedAt must be a valid date." }), +}); + +export const UpdateNoteBodySchema = z.object({ + title: z + .string() + .trim() + .min(1, { message: "Title must be at least 1 character." }) + .optional(), + content: z + .string() + .trim() + .min(1, { message: "Content must be at least 1 character." }) + .optional(), +}); + +// valitate query string +const validSortFields = [ + "title", + "-title", + "createdAt", + "-createdAt", + "updatedAt", + "-updatedAt", +]; + +export const GetAllNotesQuerySchema = z.object({ + courseId: z + .string() + .cuid({ message: "courseId must be a valid CUID." }) + .optional(), + topicId: z + .string() + .cuid({ message: "topicId must be a valid CUID." }) + .optional(), + search: z.string().optional(), + sort: z + .string() + .refine( + (value) => { + // Ensure every comma-separated value is in whitelist + return value + .split(",") + .every((field) => validSortFields.includes(field)); + }, + { message: `Invalid sort field.` }, + ) + .optional(), + page: z.coerce + .number({ message: "page must be an number." }) + .int({ message: "page must be an integer." }) + .min(1, { message: "page must be at least 1." }) + .default(1), + limit: z.coerce + .number({ message: "limit must be an number." }) + .int({ message: "limit must be an integer." }) + .min(1, { message: "limit must be at least 1." }) + .default(10), +}); + +// valitate params +export const topicIdSchema = z.object({ + topicId: z.string().trim().cuid({ message: "id must be a valid CUID." }), +}); +export const noteIdSchema = z.object({ + noteId: z.string().trim().cuid({ message: "id must be a valid CUID." }), +}); + +export type NoteServiceType = z.infer; +export type UpdateNoteServiceType = z.infer; +export type CreateNoteBodyType = z.infer; +export type GetAllNotesQueryType = z.infer; diff --git a/apps/api/src/services/notes.ts b/apps/api/src/services/notes.ts new file mode 100644 index 0000000..2f0d3e7 --- /dev/null +++ b/apps/api/src/services/notes.ts @@ -0,0 +1,155 @@ +import { NotFoundError } from "../errors/not-found.js"; +import PermissionDenied from "../errors/permission-denied.js"; +import { Note, Prisma } from "../generated/prisma/client.js"; +import { Not } from "../generated/prisma/internal/prismaNamespace.js"; +import { prisma } from "../lib/prisma.js"; +import { + CreateNoteBodyType, + GetAllNotesQueryType, + NoteServiceType, + UpdateNoteServiceType, +} from "../schemas/notes.js"; + +interface PaginatedNotesResult { + pagination: { + totalNotes: number; + totalPages: number; + currentPage: number; + limit: number; + }; + data: NoteServiceType[]; +} + +export async function createNote( + input: CreateNoteBodyType, + topicId: string, + userId: string, +): Promise { + const topic = await prisma.topic.findUnique({ + where: { id: topicId }, + select: { courseId: true }, + }); + + if (!topic) { + throw new NotFoundError(); + } + + const createdNote = await prisma.note.create({ + data: { + title: input.title, + content: input.content, + topicId, + userId, + courseId: topic.courseId, + }, + }); + + return createdNote; +} + +export async function getNoteById( + noteId: string, + userId: string, +): Promise { + const note = await prisma.note.findFirst({ + where: { + id: noteId, + userId: userId, // Authorization check + }, + }); + + if (!note) { + throw new NotFoundError(); + } + + return note; +} + +export async function updateNote( + noteId: string, + userId: string, + data: UpdateNoteServiceType, +): Promise { + const note = await prisma.note.findFirst({ + where: { + id: noteId, + userId: userId, + }, + }); + + if (!note) { + throw new NotFoundError(); + } + + const updatedNote = await prisma.note.update({ + where: { id: noteId }, + data, + }); + + return updatedNote; +} + +export async function deleteNote( + noteId: string, + userId: string, +): Promise { + const note = await prisma.note.findFirst({ + where: { + id: noteId, + userId: userId, + }, + }); + + if (!note) { + throw new NotFoundError(); + } + + await prisma.note.delete({ where: { id: noteId } }); +} + +export async function getAllFilteredNotes( + userId: string, + query: GetAllNotesQueryType, +): Promise { + const { courseId, topicId, search, sort, page, limit } = query; + + const where: Prisma.NoteWhereInput = { userId }; + if (topicId) { + where.topicId = topicId; + } else if (courseId) { + where.courseId = courseId; + } + if (search) { + where.OR = [ + { title: { contains: search, mode: "insensitive" } }, + { content: { contains: search, mode: "insensitive" } }, + ]; + } + + // default sorting by updatedAt desc + let orderBy: Prisma.NoteOrderByWithRelationInput[] = [{ updatedAt: "desc" }]; + if (sort) { + orderBy = sort.split(",").map((field) => { + const direction = field.startsWith("-") ? "desc" : "asc"; + const fieldName = field.replace(/^-/, ""); + return { [fieldName]: direction }; + }); + } + + const skip = (page - 1) * limit; + + const [notes, totalCount] = await prisma.$transaction([ + prisma.note.findMany({ where, orderBy, skip, take: limit }), + prisma.note.count({ where }), + ]); + + return { + pagination: { + totalNotes: totalCount, + totalPages: Math.ceil(totalCount / limit), + currentPage: page, + limit, + }, + data: notes, + }; +} From f6ded3d92d343b193e34c457319dcd14bceec6cd Mon Sep 17 00:00:00 2001 From: Elshahaby Date: Sat, 16 Aug 2025 20:40:43 +0300 Subject: [PATCH 18/31] fix(api): correct linting and formatting in note schemas --- apps/api/src/schemas/notes.ts | 17 ++++++----------- apps/api/src/services/notes.ts | 4 +--- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/apps/api/src/schemas/notes.ts b/apps/api/src/schemas/notes.ts index 3bbbcf3..37944d6 100644 --- a/apps/api/src/schemas/notes.ts +++ b/apps/api/src/schemas/notes.ts @@ -5,12 +5,9 @@ export const CreateNoteBodySchema = z.object({ .string() .trim() .min(1, { message: "Title is required and must be at least 1 character." }), - content: z - .string() - .trim() - .min(1, { - message: "Content is required and must be at least 1 character.", - }), + content: z.string().trim().min(1, { + message: "Content is required and must be at least 1 character.", + }), }); export const NoteSchema = z.object({ @@ -40,14 +37,14 @@ export const UpdateNoteBodySchema = z.object({ }); // valitate query string -const validSortFields = [ +const validSortFields = new Set([ "title", "-title", "createdAt", "-createdAt", "updatedAt", "-updatedAt", -]; +]); export const GetAllNotesQuerySchema = z.object({ courseId: z @@ -64,9 +61,7 @@ export const GetAllNotesQuerySchema = z.object({ .refine( (value) => { // Ensure every comma-separated value is in whitelist - return value - .split(",") - .every((field) => validSortFields.includes(field)); + return value.split(",").every((field) => validSortFields.has(field)); }, { message: `Invalid sort field.` }, ) diff --git a/apps/api/src/services/notes.ts b/apps/api/src/services/notes.ts index 2f0d3e7..913eb93 100644 --- a/apps/api/src/services/notes.ts +++ b/apps/api/src/services/notes.ts @@ -1,7 +1,5 @@ import { NotFoundError } from "../errors/not-found.js"; -import PermissionDenied from "../errors/permission-denied.js"; -import { Note, Prisma } from "../generated/prisma/client.js"; -import { Not } from "../generated/prisma/internal/prismaNamespace.js"; +import { Prisma } from "../generated/prisma/client.js"; import { prisma } from "../lib/prisma.js"; import { CreateNoteBodyType, From d6850961dc7c6f46b712088877b0f1de5468fa0d Mon Sep 17 00:00:00 2001 From: ahmed elgaml Date: Sun, 17 Aug 2025 00:19:16 +0300 Subject: [PATCH 19/31] replace the completed course array with set --- apps/api/src/routes/courses.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/api/src/routes/courses.ts b/apps/api/src/routes/courses.ts index 2bc4bd6..6414a44 100644 --- a/apps/api/src/routes/courses.ts +++ b/apps/api/src/routes/courses.ts @@ -45,13 +45,15 @@ router.get( req.params.courseId, ); + // const completedTopicsSet = new Set(completedTopics) let filterdTopics; if (completed === "true") { filterdTopics = completedTopics; } else if (completed === "false") { - const completedTopicsIds = completedTopics.map((ele) => ele.topic.id); + const completedTopicsIdsSet = new Set(); + completedTopics.forEach((ele) => completedTopicsIdsSet.add(ele)); const unCompletedTopics = totalTopics.filter( - (ele) => !completedTopicsIds.includes(ele.id), + (ele) => !completedTopicsIdsSet.has(ele.id), ); filterdTopics = unCompletedTopics; } else { From 73359422286583dc1b7c10861817dd91681c60fd Mon Sep 17 00:00:00 2001 From: ahmed elgaml Date: Sun, 17 Aug 2025 00:37:49 +0300 Subject: [PATCH 20/31] replace the forEach with for of --- apps/api/src/routes/courses.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/courses.ts b/apps/api/src/routes/courses.ts index 6414a44..b831ef3 100644 --- a/apps/api/src/routes/courses.ts +++ b/apps/api/src/routes/courses.ts @@ -45,15 +45,16 @@ router.get( req.params.courseId, ); - // const completedTopicsSet = new Set(completedTopics) let filterdTopics; if (completed === "true") { filterdTopics = completedTopics; } else if (completed === "false") { - const completedTopicsIdsSet = new Set(); - completedTopics.forEach((ele) => completedTopicsIdsSet.add(ele)); + const completedTopicsIds = new Set(); + for (const topic of completedTopics) { + completedTopicsIds.add(topic.id); + } const unCompletedTopics = totalTopics.filter( - (ele) => !completedTopicsIdsSet.has(ele.id), + (ele) => !completedTopicsIds.has(ele.id), ); filterdTopics = unCompletedTopics; } else { From a60b30f428a5f35f0ea3bcc4be8838df5c1c8b4b Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Tue, 19 Aug 2025 22:46:08 +0300 Subject: [PATCH 21/31] feat(daily-quiz): add quizDate field and update unique constraint for DailyQuiz model --- apps/api/prisma/schema.prisma | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b83210a..2454dea 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -139,8 +139,9 @@ model DailyQuiz { updatedAt DateTime @updatedAt submittedAt DateTime? totalQuestions Int @default(10) + quizDate DateTime // Should be set to the date (midnight) of the quiz, without time component - @@unique([userId, createdAt]) + @@unique([userId, quizDate]) } model User { From 498b256403f8ea2d0d0f0c168c7d05854b14677f Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Tue, 19 Aug 2025 22:46:13 +0300 Subject: [PATCH 22/31] feat(calendar): implement getQuizSubmissionCalendar function to retrieve quiz submission days for a given month --- .../api/src/controller/calendar.controller.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 apps/api/src/controller/calendar.controller.ts diff --git a/apps/api/src/controller/calendar.controller.ts b/apps/api/src/controller/calendar.controller.ts new file mode 100644 index 0000000..d7b2d15 --- /dev/null +++ b/apps/api/src/controller/calendar.controller.ts @@ -0,0 +1,84 @@ +import { Request, Response } from "express"; +import type { Session, User } from "better-auth"; +import { prisma } from "../lib/prisma.js"; + +declare global { + // Ensure session typing just like other controllers + namespace Express { + interface Request { + session?: Session; + user?: User; + } + } +} + +interface ApiResponse { + status: string; + message?: string; + data?: T; +} +const send = (res: Response, code: number, body: ApiResponse) => + res.status(code).json(body); + +// Utility to get month boundaries in local time (00:00:00.000 inclusive to next month start exclusive) +const monthRange = (year: number, month0: number) => { + const start = new Date(year, month0, 1, 0, 0, 0, 0); + const end = new Date(year, month0 + 1, 1, 0, 0, 0, 0); + return { start, end }; +}; + +/** + * Returns an array of booleans (index 0 = day 1) for the requested month indicating + * which days the user submitted a quiz (DailyQuiz.submittedAt not null). + * Query params: year=YYYY, month=1-12 (defaults to current year/month if omitted) + */ +export const getQuizSubmissionCalendar = async ( + req: Request, + res: Response +): Promise => { + if (!req.session) { + send(res, 401, { status: "fail", message: "Unauthorized" }); + return; + } + const userId = req.session.userId; + + // Parse month/year with fallbacks + const now = new Date(); + const year = Number(req.query.year) || now.getFullYear(); + const monthParam = Number(req.query.month); // 1-12 + const month0 = + monthParam && monthParam >= 1 && monthParam <= 12 ? + monthParam - 1 + : now.getMonth(); + + try { + const { start, end } = monthRange(year, month0); + const daysInMonth = new Date(year, month0 + 1, 0).getDate(); + const days: boolean[] = Array(daysInMonth).fill(false); + + const submissions = await prisma.dailyQuiz.findMany({ + where: { + userId, + submittedAt: { not: null, gte: start, lt: end }, + }, + select: { submittedAt: true }, + }); + + for (const s of submissions) { + if (!s.submittedAt) continue; + const day = s.submittedAt.getDate(); // 1-based + if (day >= 1 && day <= daysInMonth) days[day - 1] = true; + } + + send(res, 200, { + status: "success", + data: { year, month: month0 + 1, days }, + }); + } catch (err) { + console.error("[getQuizSubmissionCalendar] error", err); + send(res, 500, { + status: "error", + message: "Failed to fetch calendar", + }); + } +}; From 4960098e21f68f4895cf44f17d2be73e6ae5672e Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Tue, 19 Aug 2025 22:46:18 +0300 Subject: [PATCH 23/31] feat(calendar): remove getQuizSubmissionCalendar function and associated code --- .../api/src/controller/calender.controller.ts | 84 ------------------- 1 file changed, 84 deletions(-) delete mode 100644 apps/api/src/controller/calender.controller.ts diff --git a/apps/api/src/controller/calender.controller.ts b/apps/api/src/controller/calender.controller.ts deleted file mode 100644 index d7b2d15..0000000 --- a/apps/api/src/controller/calender.controller.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Request, Response } from "express"; -import type { Session, User } from "better-auth"; -import { prisma } from "../lib/prisma.js"; - -declare global { - // Ensure session typing just like other controllers - namespace Express { - interface Request { - session?: Session; - user?: User; - } - } -} - -interface ApiResponse { - status: string; - message?: string; - data?: T; -} -const send = (res: Response, code: number, body: ApiResponse) => - res.status(code).json(body); - -// Utility to get month boundaries in local time (00:00:00.000 inclusive to next month start exclusive) -const monthRange = (year: number, month0: number) => { - const start = new Date(year, month0, 1, 0, 0, 0, 0); - const end = new Date(year, month0 + 1, 1, 0, 0, 0, 0); - return { start, end }; -}; - -/** - * Returns an array of booleans (index 0 = day 1) for the requested month indicating - * which days the user submitted a quiz (DailyQuiz.submittedAt not null). - * Query params: year=YYYY, month=1-12 (defaults to current year/month if omitted) - */ -export const getQuizSubmissionCalendar = async ( - req: Request, - res: Response -): Promise => { - if (!req.session) { - send(res, 401, { status: "fail", message: "Unauthorized" }); - return; - } - const userId = req.session.userId; - - // Parse month/year with fallbacks - const now = new Date(); - const year = Number(req.query.year) || now.getFullYear(); - const monthParam = Number(req.query.month); // 1-12 - const month0 = - monthParam && monthParam >= 1 && monthParam <= 12 ? - monthParam - 1 - : now.getMonth(); - - try { - const { start, end } = monthRange(year, month0); - const daysInMonth = new Date(year, month0 + 1, 0).getDate(); - const days: boolean[] = Array(daysInMonth).fill(false); - - const submissions = await prisma.dailyQuiz.findMany({ - where: { - userId, - submittedAt: { not: null, gte: start, lt: end }, - }, - select: { submittedAt: true }, - }); - - for (const s of submissions) { - if (!s.submittedAt) continue; - const day = s.submittedAt.getDate(); // 1-based - if (day >= 1 && day <= daysInMonth) days[day - 1] = true; - } - - send(res, 200, { - status: "success", - data: { year, month: month0 + 1, days }, - }); - } catch (err) { - console.error("[getQuizSubmissionCalendar] error", err); - send(res, 500, { - status: "error", - message: "Failed to fetch calendar", - }); - } -}; From 3f812642e76b8f234ac1bdc502687a7c6d921ea9 Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Tue, 19 Aug 2025 22:46:24 +0300 Subject: [PATCH 24/31] fix(quizzes): correct import path for getQuizSubmissionCalendar function --- apps/api/src/routes/quizzes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/routes/quizzes.ts b/apps/api/src/routes/quizzes.ts index 44c0f5c..986ae87 100644 --- a/apps/api/src/routes/quizzes.ts +++ b/apps/api/src/routes/quizzes.ts @@ -3,7 +3,7 @@ import { validate } from "../middlewares/validate.js"; import { requireAuth } from "../middlewares/auth.js"; import { submitDailyQuizBodySchema } from "../schemas/quizzes.js"; import { getQuiz, submitQuiz } from "../controller/quiz.controller.js"; -import { getQuizSubmissionCalendar } from "../controller/calender.controller.js"; +import { getQuizSubmissionCalendar } from "../controller/calendar.controller.js"; const router = Router(); From 309a7e2395108e9a4c1a78fd593ee1e0be3453e9 Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Tue, 19 Aug 2025 22:46:31 +0300 Subject: [PATCH 25/31] chore(ai.service): no code changes made --- apps/api/src/services/ai.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/services/ai.service.ts b/apps/api/src/services/ai.service.ts index 12848a9..f37d205 100644 --- a/apps/api/src/services/ai.service.ts +++ b/apps/api/src/services/ai.service.ts @@ -2,8 +2,9 @@ import axios from "axios"; export const fetchAiRecommendation = async (quizData: any) => { try { + const aiApiUrl = process.env.AI_API_URL || "http://localhost:5000/api/data"; const response = await axios.post( - "http://localhost:5000/api/data", + aiApiUrl, quizData ); return response.data; From ffd1bbe9eef09d31d3f775322e8ba662d42bd9d1 Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Tue, 19 Aug 2025 22:46:45 +0300 Subject: [PATCH 26/31] fix(quiz-submission): replace hardcoded value with constant for invalid question index --- apps/api/src/services/quiz-submission.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/src/services/quiz-submission.service.ts b/apps/api/src/services/quiz-submission.service.ts index fab58a5..f957198 100644 --- a/apps/api/src/services/quiz-submission.service.ts +++ b/apps/api/src/services/quiz-submission.service.ts @@ -19,6 +19,8 @@ export interface GradeQuizResult { scorePercentage: number; // 0-100 } +const INVALID_QUESTION_INDEX = -1; + /** * Grades a batch of answers. Missing or invalid questions are ignored but still counted toward total if they existed in input. */ @@ -53,7 +55,7 @@ export const gradeAnswers = async ( return { questionId: a.questionId, userChoiceIndex: a.choiceIndex ?? null, - correctOptionIndex: -1, + correctOptionIndex: INVALID_QUESTION_INDEX, isCorrect: false, }; } From fc0686b2e1724226ae73e16c14498cbadeea909c Mon Sep 17 00:00:00 2001 From: NaderMohamed325 Date: Tue, 19 Aug 2025 22:47:41 +0300 Subject: [PATCH 27/31] feat(migration): add quizDate column and unique constraint to DailyQuiz table --- .../20250819194732_adding_quiz_date/migration.sql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 apps/api/prisma/migrations/20250819194732_adding_quiz_date/migration.sql diff --git a/apps/api/prisma/migrations/20250819194732_adding_quiz_date/migration.sql b/apps/api/prisma/migrations/20250819194732_adding_quiz_date/migration.sql new file mode 100644 index 0000000..0171224 --- /dev/null +++ b/apps/api/prisma/migrations/20250819194732_adding_quiz_date/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - A unique constraint covering the columns `[userId,quizDate]` on the table `DailyQuiz` will be added. If there are existing duplicate values, this will fail. + - Added the required column `quizDate` to the `DailyQuiz` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropIndex +DROP INDEX "DailyQuiz_userId_createdAt_key"; + +-- AlterTable +ALTER TABLE "DailyQuiz" ADD COLUMN "quizDate" TIMESTAMP(3) NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "DailyQuiz_userId_quizDate_key" ON "DailyQuiz"("userId", "quizDate"); From 2992d226fbb6cd86c537048a8065b50b1f0580e7 Mon Sep 17 00:00:00 2001 From: Seif El-Din Sweilam Date: Sat, 30 Aug 2025 19:34:11 +0300 Subject: [PATCH 28/31] Refactor API structure and add Postman collection - Restructured routes and controllers - Added comprehensive Postman collection with all API endpoints - Removed deprecated controller files - Added new service files for better organization - Updated schemas and middleware --- OpenLearnPlatform-API.postman_collection.json | 1071 +++++++++++++++++ apps/api/eslint.config.mjs | 1 + apps/api/package.json | 2 +- .../migration.sql | 12 + apps/api/prisma/schema.prisma | 5 - apps/api/src/app.ts | 12 +- .../api/src/controller/calendar.controller.ts | 84 -- apps/api/src/controller/quiz.controller.ts | 130 -- apps/api/src/errors/already-submitted-quiz.ts | 12 + apps/api/src/errors/not-enough-topics.ts | 12 + apps/api/src/helpers/dates.ts | 11 + apps/api/src/middlewares/auth.ts | 7 +- apps/api/src/middlewares/enhanced-send.ts | 28 + apps/api/src/router.ts | 14 + apps/api/src/routes/courses.ts | 40 +- apps/api/src/routes/index.ts | 17 - apps/api/src/routes/notes.ts | 62 +- apps/api/src/routes/quizzes.ts | 75 +- apps/api/src/routes/topics.ts | 30 +- apps/api/src/routes/tracks.ts | 52 +- apps/api/src/schemas/courses.ts | 2 +- apps/api/src/schemas/notes.ts | 17 - apps/api/src/schemas/pagination.ts | 6 + apps/api/src/schemas/quizzes.ts | 22 +- apps/api/src/services/ai.service.ts | 15 - apps/api/src/services/courses.ts | 19 +- apps/api/src/services/daily-quiz.service.ts | 67 -- apps/api/src/services/notes.ts | 28 +- .../{question.service.ts => questions.ts} | 8 +- apps/api/src/services/quiz-data.service.ts | 97 -- .../src/services/quiz-submission.service.ts | 77 -- apps/api/src/services/quizzes.ts | 182 +++ apps/api/src/services/recommendations.ts | 13 + apps/api/src/services/topics.ts | 30 +- pnpm-lock.yaml | 80 +- 35 files changed, 1614 insertions(+), 726 deletions(-) create mode 100644 OpenLearnPlatform-API.postman_collection.json create mode 100644 apps/api/prisma/migrations/20250821101109_remove_performance_records_from_topic/migration.sql delete mode 100644 apps/api/src/controller/calendar.controller.ts delete mode 100644 apps/api/src/controller/quiz.controller.ts create mode 100644 apps/api/src/errors/already-submitted-quiz.ts create mode 100644 apps/api/src/errors/not-enough-topics.ts create mode 100644 apps/api/src/helpers/dates.ts create mode 100644 apps/api/src/middlewares/enhanced-send.ts create mode 100644 apps/api/src/router.ts delete mode 100644 apps/api/src/routes/index.ts create mode 100644 apps/api/src/schemas/pagination.ts delete mode 100644 apps/api/src/services/ai.service.ts delete mode 100644 apps/api/src/services/daily-quiz.service.ts rename apps/api/src/services/{question.service.ts => questions.ts} (64%) delete mode 100644 apps/api/src/services/quiz-data.service.ts delete mode 100644 apps/api/src/services/quiz-submission.service.ts create mode 100644 apps/api/src/services/quizzes.ts create mode 100644 apps/api/src/services/recommendations.ts diff --git a/OpenLearnPlatform-API.postman_collection.json b/OpenLearnPlatform-API.postman_collection.json new file mode 100644 index 0000000..aa1d9f2 --- /dev/null +++ b/OpenLearnPlatform-API.postman_collection.json @@ -0,0 +1,1071 @@ +{ + "info": { + "name": "OpenLearnPlatform API", + "description": "Complete API collection for OpenLearnPlatform with authentication, tracks, courses, topics, notes, and quizzes endpoints", + "version": "1.0.0", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "variable": [ + { + "key": "base_url", + "value": "http://localhost:3000", + "type": "string" + }, + { + "key": "api_prefix", + "value": "/api", + "type": "string" + }, + { + "key": "auth_token", + "value": "", + "type": "string" + } + ], + "item": [ + { + "name": "Tracks", + "item": [ + { + "name": "Get All Tracks", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/tracks", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "tracks"] + }, + "description": "Retrieve all available learning tracks" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/tracks", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "tracks"] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 200,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": [\n {\n \"id\": \"track_1\",\n \"name\": \"Frontend Development\",\n \"description\": \"Learn modern frontend technologies\",\n \"icon\": \"frontend-icon.svg\",\n \"createdAt\": \"2024-01-01T00:00:00.000Z\",\n \"updatedAt\": \"2024-01-01T00:00:00.000Z\"\n }\n ]\n}" + } + ] + }, + { + "name": "Get Track by ID", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/tracks/:trackId?levelId=beginner", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "tracks", ":trackId"], + "query": [ + { + "key": "levelId", + "value": "beginner", + "description": "Optional filter by level" + } + ], + "variable": [ + { + "key": "trackId", + "value": "track_1", + "description": "Track identifier (required)" + } + ] + }, + "description": "Get specific track with its courses, optionally filtered by level" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/tracks/track_1?levelId=beginner", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "tracks", "track_1"], + "query": [ + { + "key": "levelId", + "value": "beginner" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 200,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": {\n \"id\": \"track_1\",\n \"name\": \"Frontend Development\",\n \"description\": \"Learn modern frontend technologies\",\n \"icon\": \"frontend-icon.svg\",\n \"courses\": [\n {\n \"id\": \"course_1\",\n \"name\": \"HTML Basics\",\n \"description\": \"Introduction to HTML\",\n \"level\": \"beginner\"\n }\n ]\n }\n}" + } + ] + } + ], + "description": "Learning tracks management" + }, + { + "name": "Courses", + "item": [ + { + "name": "Get Course by ID", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/courses/:courseId", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "courses", ":courseId"], + "variable": [ + { + "key": "courseId", + "value": "course_1", + "description": "Course identifier (required)" + } + ] + }, + "description": "Get course details with topics and completion percentage" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/courses/course_1", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "courses", "course_1"] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 200,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": {\n \"id\": \"course_1\",\n \"name\": \"HTML Basics\",\n \"description\": \"Introduction to HTML\",\n \"level\": \"beginner\",\n \"topics\": [\n {\n \"id\": \"topic_1\",\n \"name\": \"HTML Elements\",\n \"content\": \"Learn about HTML elements\"\n }\n ],\n \"completedPercentage\": 75.5\n }\n}" + } + ] + }, + { + "name": "Get Course Topics", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/courses/:courseId/topics?completed=true", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "courses", ":courseId", "topics"], + "query": [ + { + "key": "completed", + "value": "true", + "description": "Filter by completion status (true/false)" + } + ], + "variable": [ + { + "key": "courseId", + "value": "course_1", + "description": "Course identifier (required)" + } + ] + }, + "description": "Get all topics for a course, optionally filtered by completion status" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/courses/course_1/topics?completed=true", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "courses", "course_1", "topics"], + "query": [ + { + "key": "completed", + "value": "true" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 200,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": [\n {\n \"id\": \"topic_1\",\n \"name\": \"HTML Elements\",\n \"content\": \"Learn about HTML elements\",\n \"completed\": true,\n \"completedAt\": \"2024-08-25T10:00:00.000Z\"\n }\n ]\n}" + } + ] + } + ], + "description": "Course management and topic tracking" + }, + { + "name": "Topics", + "item": [ + { + "name": "Get Topic by ID", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/topics/:topicId", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "topics", ":topicId"], + "variable": [ + { + "key": "topicId", + "value": "topic_1", + "description": "Topic identifier (required)" + } + ] + }, + "description": "Get detailed information about a specific topic" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/topics/topic_1", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "topics", "topic_1"] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 200,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": {\n \"id\": \"topic_1\",\n \"name\": \"HTML Elements\",\n \"content\": \"Learn about HTML elements and their structure\",\n \"courseId\": \"course_1\",\n \"order\": 1,\n \"completed\": false,\n \"createdAt\": \"2024-01-01T00:00:00.000Z\",\n \"updatedAt\": \"2024-01-01T00:00:00.000Z\"\n }\n}" + } + ] + }, + { + "name": "Mark Topic as Completed", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/topics/:topicId/completion", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "topics", ":topicId", "completion"], + "variable": [ + { + "key": "topicId", + "value": "topic_1", + "description": "Topic identifier (required)" + } + ] + }, + "description": "Mark a topic as completed for the authenticated user" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "POST", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/topics/topic_1/completion", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "topics", "topic_1", "completion"] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 201,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": {\n \"id\": \"completion_1\",\n \"userId\": \"user_123\",\n \"topicId\": \"topic_1\",\n \"completedAt\": \"2024-08-30T10:00:00.000Z\"\n }\n}" + } + ] + }, + { + "name": "Unmark Topic as Completed", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/topics/:topicId/completion", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "topics", ":topicId", "completion"], + "variable": [ + { + "key": "topicId", + "value": "topic_1", + "description": "Topic identifier (required)" + } + ] + }, + "description": "Remove completion status from a topic for the authenticated user" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/topics/topic_1/completion", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "topics", "topic_1", "completion"] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 204,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": null\n}" + } + ] + } + ], + "description": "Topic completion tracking" + }, + { + "name": "Notes", + "item": [ + { + "name": "Get All Notes", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/notes?courseId=course_1&topicId=topic_1&search=html&sort=createdAt,-title&page=1&limit=10", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "notes"], + "query": [ + { + "key": "courseId", + "value": "course_1", + "description": "Filter by course ID (CUID)" + }, + { + "key": "topicId", + "value": "topic_1", + "description": "Filter by topic ID (CUID)" + }, + { + "key": "search", + "value": "html", + "description": "Search in title and content" + }, + { + "key": "sort", + "value": "createdAt,-title", + "description": "Sort fields (title, -title, createdAt, -createdAt, updatedAt, -updatedAt)" + }, + { + "key": "page", + "value": "1", + "description": "Page number (default: 1)" + }, + { + "key": "limit", + "value": "10", + "description": "Items per page (default: 10)" + } + ] + }, + "description": "Get all notes for the authenticated user with filtering, searching, sorting, and pagination" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/notes?page=1&limit=10", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "notes"], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "limit", + "value": "10" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 200,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": [\n {\n \"id\": \"note_1\",\n \"title\": \"HTML Elements Notes\",\n \"content\": \"Important points about HTML elements...\",\n \"topicId\": \"topic_1\",\n \"userId\": \"user_123\",\n \"createdAt\": \"2024-08-30T10:00:00.000Z\",\n \"updatedAt\": \"2024-08-30T10:00:00.000Z\"\n }\n ],\n \"pagination\": {\n \"totalItems\": 25,\n \"totalPages\": 3,\n \"currentPage\": 1,\n \"itemsPerPage\": 10\n }\n}" + } + ] + }, + { + "name": "Get Note by ID", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/notes/:noteId", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "notes", ":noteId"], + "variable": [ + { + "key": "noteId", + "value": "note_1", + "description": "Note identifier (CUID, required)" + } + ] + }, + "description": "Get a specific note by its ID" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/notes/note_1", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "notes", "note_1"] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 200,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": {\n \"id\": \"note_1\",\n \"title\": \"HTML Elements Notes\",\n \"content\": \"Important points about HTML elements and their structure...\",\n \"topicId\": \"topic_1\",\n \"userId\": \"user_123\",\n \"createdAt\": \"2024-08-30T10:00:00.000Z\",\n \"updatedAt\": \"2024-08-30T10:00:00.000Z\"\n }\n}" + } + ] + }, + { + "name": "Create Note", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"My HTML Notes\",\n \"content\": \"These are my notes about HTML elements and their usage.\",\n \"topicId\": \"clabcd1234567890abcdef12\"\n}" + }, + "url": { + "raw": "{{base_url}}{{api_prefix}}/notes", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "notes"] + }, + "description": "Create a new note for a specific topic" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"My HTML Notes\",\n \"content\": \"These are my notes about HTML elements and their usage.\",\n \"topicId\": \"clabcd1234567890abcdef12\"\n}" + }, + "url": { + "raw": "{{base_url}}{{api_prefix}}/notes", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "notes"] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 201,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": {\n \"id\": \"clnew1234567890abcdef12\",\n \"title\": \"My HTML Notes\",\n \"content\": \"These are my notes about HTML elements and their usage.\",\n \"topicId\": \"clabcd1234567890abcdef12\",\n \"userId\": \"user_123\",\n \"createdAt\": \"2024-08-30T10:00:00.000Z\",\n \"updatedAt\": \"2024-08-30T10:00:00.000Z\"\n }\n}" + } + ] + }, + { + "name": "Update Note", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Updated HTML Notes\",\n \"content\": \"Updated content about HTML elements with more details.\"\n}" + }, + "url": { + "raw": "{{base_url}}{{api_prefix}}/notes/:noteId", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "notes", ":noteId"], + "variable": [ + { + "key": "noteId", + "value": "note_1", + "description": "Note identifier (CUID, required)" + } + ] + }, + "description": "Update an existing note (title and/or content are optional)" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Updated HTML Notes\",\n \"content\": \"Updated content about HTML elements with more details.\"\n}" + }, + "url": { + "raw": "{{base_url}}{{api_prefix}}/notes/note_1", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "notes", "note_1"] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 200,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": {\n \"id\": \"note_1\",\n \"title\": \"Updated HTML Notes\",\n \"content\": \"Updated content about HTML elements with more details.\",\n \"topicId\": \"topic_1\",\n \"userId\": \"user_123\",\n \"createdAt\": \"2024-08-30T09:00:00.000Z\",\n \"updatedAt\": \"2024-08-30T10:00:00.000Z\"\n }\n}" + } + ] + }, + { + "name": "Delete Note", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/notes/:noteId", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "notes", ":noteId"], + "variable": [ + { + "key": "noteId", + "value": "note_1", + "description": "Note identifier (CUID, required)" + } + ] + }, + "description": "Delete a note permanently" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/notes/note_1", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "notes", "note_1"] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 204,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": null\n}" + } + ] + } + ], + "description": "User notes management with full CRUD operations" + }, + { + "name": "Quizzes", + "item": [ + { + "name": "Get Quiz Calendar", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/quizzes/calendar?month=8&year=2024", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "quizzes", "calendar"], + "query": [ + { + "key": "month", + "value": "8", + "description": "Month (1-12, optional - defaults to current month)" + }, + { + "key": "year", + "value": "2024", + "description": "Year (2000-2100, optional - defaults to current year)" + } + ] + }, + "description": "Get calendar view of quiz submissions for a specific month and year" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/quizzes/calendar?month=8&year=2024", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "quizzes", "calendar"], + "query": [ + { + "key": "month", + "value": "8" + }, + { + "key": "year", + "value": "2024" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 200,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": {\n \"year\": 2024,\n \"month\": 8,\n \"days\": [\n {\n \"day\": 1,\n \"hasSubmission\": true,\n \"score\": 85.5\n },\n {\n \"day\": 2,\n \"hasSubmission\": false,\n \"score\": null\n },\n {\n \"day\": 30,\n \"hasSubmission\": true,\n \"score\": 92.0\n }\n ]\n }\n}" + } + ] + }, + { + "name": "Get Daily Quiz", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/quizzes/daily", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "quizzes", "daily"] + }, + "description": "Get or generate today's quiz for the authenticated user. If already submitted, returns AlreadySubmittedQuiz error." + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/quizzes/daily", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "quizzes", "daily"] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 200,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": {\n \"quiz\": {\n \"id\": \"quiz_123\",\n \"userId\": \"user_123\",\n \"date\": \"2024-08-30T00:00:00.000Z\",\n \"totalQuestions\": 10,\n \"submittedAt\": null,\n \"score\": null\n },\n \"questions\": [\n {\n \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"question\": \"What does HTML stand for?\",\n \"choices\": [\n \"Hypertext Markup Language\",\n \"High Tech Modern Language\",\n \"Home Tool Markup Language\",\n \"Hyperlink and Text Markup Language\"\n ],\n \"difficulty\": \"beginner\",\n \"topic\": \"HTML Basics\"\n }\n ],\n \"aiRecommendation\": {\n \"recommendedTopics\": [\n {\n \"topic\": \"HTML Basics\",\n \"difficulty\": \"beginner\",\n \"count\": 5\n },\n {\n \"topic\": \"CSS Fundamentals\",\n \"difficulty\": \"beginner\",\n \"count\": 3\n }\n ],\n \"reasoning\": \"Based on your progress, focusing on HTML basics will strengthen your foundation.\"\n }\n }\n}" + }, + { + "name": "Already Submitted Error", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/quizzes/daily", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "quizzes", "daily"] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": false,\n \"statusCode\": 400,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"error\": {\n \"name\": \"AlreadySubmittedQuiz\",\n \"message\": \"You have already submitted today's quiz\"\n }\n}" + }, + { + "name": "Not Enough Topics Error", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}{{api_prefix}}/quizzes/daily", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "quizzes", "daily"] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": false,\n \"statusCode\": 400,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"error\": {\n \"name\": \"NotEnoughTopics\",\n \"message\": \"Not enough completed topics to generate quiz\"\n }\n}" + } + ] + }, + { + "name": "Submit Daily Quiz", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"answers\": [\n {\n \"questionId\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"choiceIndex\": 0\n },\n {\n \"questionId\": \"550e8400-e29b-41d4-a716-446655440001\",\n \"choiceIndex\": 2\n },\n {\n \"questionId\": \"550e8400-e29b-41d4-a716-446655440002\",\n \"choiceIndex\": 1\n }\n ]\n}" + }, + "url": { + "raw": "{{base_url}}{{api_prefix}}/quizzes/daily", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "quizzes", "daily"] + }, + "description": "Submit answers for today's daily quiz" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"answers\": [\n {\n \"questionId\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"choiceIndex\": 0\n },\n {\n \"questionId\": \"550e8400-e29b-41d4-a716-446655440001\",\n \"choiceIndex\": 2\n },\n {\n \"questionId\": \"550e8400-e29b-41d4-a716-446655440002\",\n \"choiceIndex\": 1\n }\n ]\n}" + }, + "url": { + "raw": "{{base_url}}{{api_prefix}}/quizzes/daily", + "host": ["{{base_url}}"], + "path": ["{{api_prefix}}", "quizzes", "daily"] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"statusCode\": 200,\n \"timestamp\": \"2024-08-30T10:00:00.000Z\",\n \"data\": {\n \"score\": 86.7,\n \"correctCount\": 8,\n \"total\": 10,\n \"answers\": [\n {\n \"questionId\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"choiceIndex\": 0,\n \"isCorrect\": true,\n \"correctChoiceIndex\": 0,\n \"explanation\": \"HTML stands for Hypertext Markup Language\"\n },\n {\n \"questionId\": \"550e8400-e29b-41d4-a716-446655440001\",\n \"choiceIndex\": 2,\n \"isCorrect\": false,\n \"correctChoiceIndex\": 1,\n \"explanation\": \"The correct answer is CSS stands for Cascading Style Sheets\"\n }\n ]\n }\n}" + } + ] + } + ], + "description": "Daily quiz system with AI-powered question generation and progress tracking" + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "// Check if auth_token is set, if not, remind user to authenticate", + "const authToken = pm.collectionVariables.get('auth_token');", + "if (!authToken && pm.request.auth && pm.request.auth.type === 'bearer') {", + " console.log('⚠️ Authentication token not set. Please sign in first or set the auth_token variable.');", + "}" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "// Test for successful responses", + "pm.test('Status code is success', function () {", + " pm.expect(pm.response.code).to.be.oneOf([200, 201, 204]);", + "});", + "", + "// Test response structure", + "if (pm.response.code !== 204) {", + " pm.test('Response has required structure', function () {", + " const jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('success');", + " pm.expect(jsonData).to.have.property('statusCode');", + " pm.expect(jsonData).to.have.property('timestamp');", + " pm.expect(jsonData).to.have.property('data');", + " });", + "}", + "", + "// Auth token can be set manually in collection variables", + "// Set 'auth_token' variable with your authentication token" + ], + "type": "text/javascript" + } + } + ] +} diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs index bd60cb9..6d12476 100644 --- a/apps/api/eslint.config.mjs +++ b/apps/api/eslint.config.mjs @@ -29,6 +29,7 @@ export default [ "sonarjs/no-hardcoded-passwords": "off", "sonarjs/cors": "off", "@typescript-eslint/no-namespace": "off", + "unicorn/no-useless-undefined": "off", }, languageOptions: { globals: globals.node, diff --git a/apps/api/package.json b/apps/api/package.json index 9319bb7..96f8545 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -25,7 +25,7 @@ "dotenv": "^17.2.1", "express": "^4.21.2", "express-async-errors": "^3.1.1", - "zod": "^3.24.3" + "zod": "^4.0.17" }, "devDependencies": { "@better-auth/cli": "^1.2.12", diff --git a/apps/api/prisma/migrations/20250821101109_remove_performance_records_from_topic/migration.sql b/apps/api/prisma/migrations/20250821101109_remove_performance_records_from_topic/migration.sql new file mode 100644 index 0000000..b20c7c9 --- /dev/null +++ b/apps/api/prisma/migrations/20250821101109_remove_performance_records_from_topic/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `attempted` on the `Topic` table. All the data in the column will be lost. + - You are about to drop the column `difficulty` on the `Topic` table. All the data in the column will be lost. + - You are about to drop the column `solved` on the `Topic` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Topic" DROP COLUMN "attempted", +DROP COLUMN "difficulty", +DROP COLUMN "solved"; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f2bee33..e14ca02 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -68,11 +68,6 @@ model Topic { questions Question[] userPerformances UserTopicPerformance[] - - difficulty QuestionDifficulty @default(easy) - attempted Int @default(0) - solved Int @default(0) - notes Note[] } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4a6274d..2e46f5d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,22 +3,20 @@ import express from "express"; import { auth } from "./lib/auth.js"; import { toNodeHandler } from "better-auth/node"; import { admin, adminRouter } from "./lib/admin.js"; -import { quizzesRouter } from "./routes/quizzes.js"; -import api from "./routes/index.js"; -import { notesRouter } from "./routes/notes.js"; +import { router, ROUTES_PREFIX } from "./router.js"; +import { addEnhancedSendMethod } from "./middlewares/enhanced-send.js"; const app = express(); app.disable("x-powered-by"); app.all("/api/auth/*", toNodeHandler(auth)); + app.use(admin.options.rootPath, adminRouter); console.log(`AdminJS is running under ${admin.options.rootPath}`); app.use(express.json()); +app.use(addEnhancedSendMethod); -app.use(notesRouter); -app.use("/api", api); -app.use("/api/quizzes", quizzesRouter); - +app.use(ROUTES_PREFIX, router); export default app; diff --git a/apps/api/src/controller/calendar.controller.ts b/apps/api/src/controller/calendar.controller.ts deleted file mode 100644 index d7b2d15..0000000 --- a/apps/api/src/controller/calendar.controller.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Request, Response } from "express"; -import type { Session, User } from "better-auth"; -import { prisma } from "../lib/prisma.js"; - -declare global { - // Ensure session typing just like other controllers - namespace Express { - interface Request { - session?: Session; - user?: User; - } - } -} - -interface ApiResponse { - status: string; - message?: string; - data?: T; -} -const send = (res: Response, code: number, body: ApiResponse) => - res.status(code).json(body); - -// Utility to get month boundaries in local time (00:00:00.000 inclusive to next month start exclusive) -const monthRange = (year: number, month0: number) => { - const start = new Date(year, month0, 1, 0, 0, 0, 0); - const end = new Date(year, month0 + 1, 1, 0, 0, 0, 0); - return { start, end }; -}; - -/** - * Returns an array of booleans (index 0 = day 1) for the requested month indicating - * which days the user submitted a quiz (DailyQuiz.submittedAt not null). - * Query params: year=YYYY, month=1-12 (defaults to current year/month if omitted) - */ -export const getQuizSubmissionCalendar = async ( - req: Request, - res: Response -): Promise => { - if (!req.session) { - send(res, 401, { status: "fail", message: "Unauthorized" }); - return; - } - const userId = req.session.userId; - - // Parse month/year with fallbacks - const now = new Date(); - const year = Number(req.query.year) || now.getFullYear(); - const monthParam = Number(req.query.month); // 1-12 - const month0 = - monthParam && monthParam >= 1 && monthParam <= 12 ? - monthParam - 1 - : now.getMonth(); - - try { - const { start, end } = monthRange(year, month0); - const daysInMonth = new Date(year, month0 + 1, 0).getDate(); - const days: boolean[] = Array(daysInMonth).fill(false); - - const submissions = await prisma.dailyQuiz.findMany({ - where: { - userId, - submittedAt: { not: null, gte: start, lt: end }, - }, - select: { submittedAt: true }, - }); - - for (const s of submissions) { - if (!s.submittedAt) continue; - const day = s.submittedAt.getDate(); // 1-based - if (day >= 1 && day <= daysInMonth) days[day - 1] = true; - } - - send(res, 200, { - status: "success", - data: { year, month: month0 + 1, days }, - }); - } catch (err) { - console.error("[getQuizSubmissionCalendar] error", err); - send(res, 500, { - status: "error", - message: "Failed to fetch calendar", - }); - } -}; diff --git a/apps/api/src/controller/quiz.controller.ts b/apps/api/src/controller/quiz.controller.ts deleted file mode 100644 index df66ce1..0000000 --- a/apps/api/src/controller/quiz.controller.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { Request, Response } from "express"; -import type { Session, User } from "better-auth"; -import { fetchAiRecommendation } from "../services/ai.service.js"; -import { buildUserQuizData } from "../services/quiz-data.service.js"; -import { fetchQuestionsByRecommendation } from "../services/question.service.js"; -import { - saveOrUpdateDailyQuiz, - findTodayDailyQuiz, -} from "../services/daily-quiz.service.js"; -import { - gradeAnswers, - SubmittedAnswerInput, -} from "../services/quiz-submission.service.js"; -import { updateDailyQuizScoreByUserToday } from "../services/daily-quiz.service.js"; - -declare global { - namespace Express { - interface Request { - session?: Session; - user?: User; - } - } -} - -// Utility helpers ------------------------------------------------------------- -const startOfDay = (d: Date) => - new Date(d.getFullYear(), d.getMonth(), d.getDate()); - -interface ApiResponse { - status: string; - message?: string; - data?: T; -} -const send = (res: Response, code: number, body: ApiResponse) => - res.status(code).json(body); - -export const getQuiz = async (req: Request, res: Response): Promise => { - if (!req.session) { - send(res, 401, { status: "fail", message: "Unauthorized" }); - return; - } - const userId = req.session.userId; - const today = new Date(); - - try { - // Fetch existing quiz (by date range) if any - const existingQuiz = await findTodayDailyQuiz(userId, today); - const totalQuestions = existingQuiz?.totalQuestions || 10; - - // Build data for AI - const quizData = await buildUserQuizData(userId, totalQuestions); - if (!quizData) { - send(res, 404, { - status: "fail", - message: "User hasn't completed any topics yet", - }); - return; - } - - const aiRecommendation = await fetchAiRecommendation(quizData); - const questions = await fetchQuestionsByRecommendation( - aiRecommendation, - totalQuestions - ); - - // Persist / update quiz record (schema only stores counts currently) - const savedQuiz = await saveOrUpdateDailyQuiz( - userId, - startOfDay(today), - totalQuestions - ); - - send(res, 200, { - status: "success", - data: { quiz: savedQuiz, questions, aiRecommendation }, - }); - } catch (err) { - console.error("[getQuiz] error", err); - send(res, 500, { - status: "error", - message: "Failed to generate quiz", - }); - } -}; - -export const submitQuiz = async ( - req: Request, - res: Response -): Promise => { - if (!req.session) { - send(res, 401, { status: "fail", message: "Unauthorized" }); - return; - } - const userId = req.session.userId; - const today = new Date(); - - try { - const { answers } = req.body as { answers: SubmittedAnswerInput[] }; - if (!Array.isArray(answers) || answers.length === 0) { - send(res, 400, { - status: "fail", - message: "answers array required", - }); - return; - } - - const grading = await gradeAnswers(answers); - await updateDailyQuizScoreByUserToday( - userId, - today, - grading.scorePercentage - ); - - send(res, 200, { - status: "success", - data: { - score: grading.scorePercentage, - correctCount: grading.correctCount, - total: grading.total, - answers: grading.graded, - }, - }); - } catch (err) { - console.error("[submitQuiz] error", err); - send(res, 500, { - status: "error", - message: "Failed to submit quiz", - }); - } -}; diff --git a/apps/api/src/errors/already-submitted-quiz.ts b/apps/api/src/errors/already-submitted-quiz.ts new file mode 100644 index 0000000..893ebab --- /dev/null +++ b/apps/api/src/errors/already-submitted-quiz.ts @@ -0,0 +1,12 @@ +import BaseError from "./base.js"; + +export default class AlreadySubmittedQuiz extends BaseError { + constructor() { + super( + "Quiz has already been submitted", + 409, + undefined, + "You will be able to submit a new task by tomorrow", + ); + } +} diff --git a/apps/api/src/errors/not-enough-topics.ts b/apps/api/src/errors/not-enough-topics.ts new file mode 100644 index 0000000..a1f97f2 --- /dev/null +++ b/apps/api/src/errors/not-enough-topics.ts @@ -0,0 +1,12 @@ +import BaseError from "./base.js"; + +export default class NotEnoughTopics extends BaseError { + constructor() { + super( + "Not enough topics completed", + 422, + undefined, + "Start to complete more topics to be able to perform this action", + ); + } +} diff --git a/apps/api/src/helpers/dates.ts b/apps/api/src/helpers/dates.ts new file mode 100644 index 0000000..f0800fd --- /dev/null +++ b/apps/api/src/helpers/dates.ts @@ -0,0 +1,11 @@ +export const getMonthInterval = (month: number, year: number) => { + const start = new Date(year, month, 1); + const end = new Date(year, month + 1, 1); + return { start, end }; +}; + +export const getStartOfDay = (d: Date) => + new Date(d.getFullYear(), d.getMonth(), d.getDate()); + +export const getNextDay = (d: Date) => + new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1); diff --git a/apps/api/src/middlewares/auth.ts b/apps/api/src/middlewares/auth.ts index c9310f3..9a2fd7d 100644 --- a/apps/api/src/middlewares/auth.ts +++ b/apps/api/src/middlewares/auth.ts @@ -12,7 +12,12 @@ declare global { } } -export const requireAuth: RequestHandler = async (req, res, next) => { +export const requireAuth: RequestHandler< + unknown, + unknown, + unknown, + unknown +> = async (req, res, next) => { const data = await auth.api.getSession({ headers: fromNodeHeaders(req.headers), }); diff --git a/apps/api/src/middlewares/enhanced-send.ts b/apps/api/src/middlewares/enhanced-send.ts new file mode 100644 index 0000000..42ced5d --- /dev/null +++ b/apps/api/src/middlewares/enhanced-send.ts @@ -0,0 +1,28 @@ +import { RequestHandler } from "express"; +import { Pagination } from "../schemas/pagination.js"; + +declare global { + namespace Express { + export interface Response { + enhancedSend: ( + statusCode: number, + data: unknown, + pagination?: Pagination, + ) => Response; + } + } +} + +export const addEnhancedSendMethod: RequestHandler = (req, res, next) => { + res.enhancedSend = (statusCode, data, pagination) => { + const success = statusCode < 400; + return res.status(statusCode).json({ + success, + statusCode, + timestamp: new Date(), + data, + pagination, + }); + }; + next(); +}; diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts new file mode 100644 index 0000000..79fabff --- /dev/null +++ b/apps/api/src/router.ts @@ -0,0 +1,14 @@ +import { Router } from "express"; +import { notesRouter } from "./routes/notes.js"; +import { tracksRouter } from "./routes/tracks.js"; +import { coursesRouter } from "./routes/courses.js"; +import { topicsRouter } from "./routes/topics.js"; + +export const ROUTES_PREFIX = "/api"; + +export const router = Router(); + +router.use("/notes", notesRouter); +router.use("/tracks", tracksRouter); +router.use("/courses", coursesRouter); +router.use("/topics", topicsRouter); diff --git a/apps/api/src/routes/courses.ts b/apps/api/src/routes/courses.ts index b831ef3..902011e 100644 --- a/apps/api/src/routes/courses.ts +++ b/apps/api/src/routes/courses.ts @@ -22,15 +22,17 @@ router.get( req.user!.id, req.params.courseId, ); - const persentage = (completedTopics.length / totalTopics.length) * 100; + const completedPercentage = + (completedTopics.length / totalTopics.length) * 100; - res.status(200).json({ - course, - totalTopics, - persentage, + res.enhancedSend(200, { + ...course, + topics: totalTopics, + completedPercentage, }); }, ); + router.get( "/:courseId/topics", requireAuth, @@ -38,32 +40,14 @@ router.get( async (req, res) => { const { completed } = req.query; - const totalTopics = await topicService.getToltalTopics(req.params.courseId); - - const completedTopics = await topicService.getCompletedTopics( - req.user!.id, + const topics = await topicService.getToltalTopics( req.params.courseId, + completed + ? { isCompleted: completed === "true", userId: req.user!.id } + : undefined, ); - let filterdTopics; - if (completed === "true") { - filterdTopics = completedTopics; - } else if (completed === "false") { - const completedTopicsIds = new Set(); - for (const topic of completedTopics) { - completedTopicsIds.add(topic.id); - } - const unCompletedTopics = totalTopics.filter( - (ele) => !completedTopicsIds.has(ele.id), - ); - filterdTopics = unCompletedTopics; - } else { - filterdTopics = totalTopics; - } - - res.status(200).json({ - filterdTopics, - }); + res.enhancedSend(200, topics); }, ); diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts deleted file mode 100644 index a66cad7..0000000 --- a/apps/api/src/routes/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import express from "express"; -const router = express.Router(); -import { tracksRouter } from "./tracks.js"; -import { coursesRouter } from "./courses.js"; -import { topicsRouter } from "./topics.js"; - -router.get("/", (req, res) => { - res.status(200).json({ - message: "hello from api.", - }); -}); - -router.use("/tracks", tracksRouter); -router.use("/courses", coursesRouter); -router.use("/topics", topicsRouter); - -export default router; diff --git a/apps/api/src/routes/notes.ts b/apps/api/src/routes/notes.ts index 1cad568..aee23a4 100644 --- a/apps/api/src/routes/notes.ts +++ b/apps/api/src/routes/notes.ts @@ -11,7 +11,6 @@ import { CreateNoteBodySchema, GetAllNotesQuerySchema, noteIdSchema, - topicIdSchema, UpdateNoteBodySchema, } from "../schemas/notes.js"; import { requireAuth } from "../middlewares/auth.js"; @@ -20,75 +19,46 @@ const router = Router(); router.use(requireAuth); -router.get( - "/api/notes/:noteId", - validate({ params: noteIdSchema }), - async (req, res) => { - const { noteId } = req.params; - const response = await getNoteById(noteId, req.user!.id); - res.status(200).json({ - status: "success", - data: response, - }); - }, -); +router.get("/:noteId", validate({ params: noteIdSchema }), async (req, res) => { + const { noteId } = req.params; + const response = await getNoteById(noteId, req.user!.id); + res.enhancedSend(200, response); +}); router.get( - "/api/notes", + "/", validate({ query: GetAllNotesQuerySchema }), async (req, res) => { const response = await getAllFilteredNotes(req.user!.id, req.query); - res.status(200).json({ - status: "success", - pagination: response.pagination, - data: response.data, - }); + res.enhancedSend(200, response.data, response.pagination); }, ); -router.post( - "/api/topics/:topicId/notes", - validate({ params: topicIdSchema, body: CreateNoteBodySchema }), - async (req, res) => { - const { topicId } = req.params; - const { title, content } = req.body; - const response = await createNote( - { title, content }, - topicId, - req.user!.id, - ); - res.status(201).json({ - status: "success", - data: response, - }); - }, -); +router.post("/", validate({ body: CreateNoteBodySchema }), async (req, res) => { + const { title, content, topicId } = req.body; + const response = await createNote({ title, content, topicId }, req.user!.id); + res.enhancedSend(201, response); +}); router.put( - "/api/notes/:noteId", + "/:noteId", validate({ params: noteIdSchema, body: UpdateNoteBodySchema }), async (req, res) => { const { noteId } = req.params; const { title, content } = req.body; const response = await updateNote(noteId, req.user!.id, { title, content }); - res.status(200).json({ - status: "success", - data: response, - }); + res.enhancedSend(200, response); }, ); router.delete( - "/api/notes/:noteId", + "/:noteId", validate({ params: noteIdSchema }), async (req, res) => { const { noteId } = req.params; await deleteNote(noteId, req.user!.id); - res.status(204).json({ - status: "success", - message: "Note deleted successfully.", - }); + res.enhancedSend(204, undefined); }, ); diff --git a/apps/api/src/routes/quizzes.ts b/apps/api/src/routes/quizzes.ts index 986ae87..cd56ff7 100644 --- a/apps/api/src/routes/quizzes.ts +++ b/apps/api/src/routes/quizzes.ts @@ -1,9 +1,22 @@ import { Router } from "express"; import { validate } from "../middlewares/validate.js"; import { requireAuth } from "../middlewares/auth.js"; -import { submitDailyQuizBodySchema } from "../schemas/quizzes.js"; -import { getQuiz, submitQuiz } from "../controller/quiz.controller.js"; -import { getQuizSubmissionCalendar } from "../controller/calendar.controller.js"; +import { + getMonthSubmissionsQuerySchema, + submitDailyQuizBodySchema, +} from "../schemas/quizzes.js"; +import { + buildUserQuizData, + createDailyQuiz, + getMonthSubmissions, + getQuizByDate, + gradeAnswers, + submitDailyQuiz, +} from "../services/quizzes.js"; +import NotEnoughTopics from "../errors/not-enough-topics.js"; +import { fetchAiRecommendation } from "../services/recommendations.js"; +import { fetchQuestionsByRecommendation } from "../services/questions.js"; +import AlreadySubmittedQuiz from "../errors/already-submitted-quiz.js"; const router = Router(); @@ -11,19 +24,67 @@ const router = Router(); router.get( "/calendar", requireAuth, - // optional validation for query could be added later - getQuizSubmissionCalendar + validate({ query: getMonthSubmissionsQuerySchema }), + async (req, res) => { + const userId = req.session!.userId; + + const now = new Date(); + const year = req.query.year || now.getFullYear(); + const month = req.query.month ? req.query.month - 1 : now.getMonth(); + + const days = await getMonthSubmissions(month, year, userId); + + res.enhancedSend(200, { year, month: month + 1, days }); + }, ); // Fetch / (re)generate today's quiz for the user -router.get("/daily", requireAuth, getQuiz); +router.get("/daily", requireAuth, async (req, res): Promise => { + const userId = req.session!.userId; + const today = new Date(); + + const existingQuiz = await getQuizByDate(userId, today); + const totalQuestions = existingQuiz?.totalQuestions || 10; + + if (existingQuiz?.submittedAt) { + throw new AlreadySubmittedQuiz(); + } + + // Build data for AI + const quizData = await buildUserQuizData(userId, totalQuestions); + if (!quizData) { + throw new NotEnoughTopics(); + } + + const aiRecommendation = await fetchAiRecommendation(quizData); + const questions = await fetchQuestionsByRecommendation( + aiRecommendation, + totalQuestions, + ); + + const quiz = existingQuiz ?? (await createDailyQuiz(userId, totalQuestions)); + + res.enhancedSend(200, { quiz, questions, aiRecommendation }); +}); // Submit answers for today's quiz router.post( "/daily", requireAuth, validate({ body: submitDailyQuizBodySchema }), - submitQuiz + async (req, res) => { + const { answers } = req.body; + + const grading = await gradeAnswers(answers); + await submitDailyQuiz(req.user!.id, grading.scorePercentage); + + res.enhancedSend(200, { + score: grading.scorePercentage, + correctCount: grading.correctCount, + total: grading.total, + answers: grading.graded, + }); + }, ); export { router as quizzesRouter }; diff --git a/apps/api/src/routes/topics.ts b/apps/api/src/routes/topics.ts index d56f2e3..c88ce4d 100644 --- a/apps/api/src/routes/topics.ts +++ b/apps/api/src/routes/topics.ts @@ -1,39 +1,43 @@ -import exprees from "express"; -const router = exprees.Router(); +import { Router } from "express"; import { validate } from "../middlewares/validate.js"; import { getTopicParamsSchema } from "../schemas/topics.js"; -import * as Service from "../services/topics.js"; import { requireAuth } from "../middlewares/auth.js"; +import { + markTopicAsCompleted, + getTopic, + unmarkTopicAsCompleted, +} from "../services/topics.js"; + +const router = Router(); router.get( "/:topicId", + requireAuth, validate({ params: getTopicParamsSchema }), async (req, res) => { - const topic = await Service.getTopic(req.params.topicId); + const topic = await getTopic(req.params.topicId); - res.status(200).json({ - topic, - }); + res.enhancedSend(200, topic); }, ); + router.post( "/:topicId/completion", requireAuth, validate({ params: getTopicParamsSchema }), async (req, res) => { - const topic = await Service.completeTopic(req.user!.id, req.params.topicId); - res.status(201).json({ - topic, - }); + const topic = await markTopicAsCompleted(req.user!.id, req.params.topicId); + res.enhancedSend(201, topic); }, ); + router.delete( "/:topicId/completion", requireAuth, validate({ params: getTopicParamsSchema }), async (req, res) => { - await Service.inCompleteTopic(req.user!.id, req.params.topicId); - res.status(204).json({}); + await unmarkTopicAsCompleted(req.user!.id, req.params.topicId); + res.enhancedSend(204, undefined); }, ); diff --git a/apps/api/src/routes/tracks.ts b/apps/api/src/routes/tracks.ts index 9776dc9..d169324 100644 --- a/apps/api/src/routes/tracks.ts +++ b/apps/api/src/routes/tracks.ts @@ -1,59 +1,31 @@ -import exprees from "express"; -const router = exprees.Router(); -import { Prisma } from "../generated/prisma/client.js"; +import { Router } from "express"; import { validate } from "../middlewares/validate.js"; import { getTrackQuerySchema, getTrackParamsSchema, } from "../schemas/tracks.js"; -import * as Service from "../services/tracks.js"; import { requireAuth } from "../middlewares/auth.js"; -import * as courseSrvice from "../services/courses.js"; +import { getAllTracks, getTrack } from "../services/tracks.js"; +import { getCourses } from "../services/courses.js"; -router.get("/", async (req, res) => { - const tracks = await Service.getAllTracks(); +const router = Router(); - res.status(200).json({ - length: tracks.length, - data: tracks, - }); +router.get("/", async (req, res) => { + const tracks = await getAllTracks(); + res.enhancedSend(200, tracks); }); + router.get( "/:trackId", requireAuth, validate({ params: getTrackParamsSchema, query: getTrackQuerySchema }), async (req, res) => { - const { levelId } = req.query as { levelId: string }; - const track = await Service.getTrack(req.params.trackId); + const { levelId } = req.query; + const track = await getTrack(req.params.trackId); - const courses = await courseSrvice.getCourses( - levelId, - req.params.trackId, - req.user!.id, - ); - const progress = courses.map( - ( - course: Prisma.CourseGetPayload<{ - include: { topics: { select: { userCompletions: true } } }; - }>, - ) => { - const totalTopics = course.topics.length; - const completedTopics = course.topics.filter( - (topic) => topic.userCompletions.length > 0, - ).length; - const percentage = (completedTopics / totalTopics) * 100; + const courses = await getCourses(levelId, req.params.trackId, req.user!.id); - return { - id: course.id, - title: course.title, - percentage, - }; - }, - ); - res.status(200).json({ - track, - courses: progress, - }); + res.enhancedSend(200, { ...track, courses }); }, ); diff --git a/apps/api/src/schemas/courses.ts b/apps/api/src/schemas/courses.ts index 3571e3c..aa7ad3f 100644 --- a/apps/api/src/schemas/courses.ts +++ b/apps/api/src/schemas/courses.ts @@ -5,5 +5,5 @@ export const getCourseParamsSchema = z.object({ }); export const getCourseQuerySchema = z.object({ - completed: z.string().optional(), + completed: z.enum(["true", "false"]).optional(), }); diff --git a/apps/api/src/schemas/notes.ts b/apps/api/src/schemas/notes.ts index 37944d6..89a341a 100644 --- a/apps/api/src/schemas/notes.ts +++ b/apps/api/src/schemas/notes.ts @@ -8,19 +8,7 @@ export const CreateNoteBodySchema = z.object({ content: z.string().trim().min(1, { message: "Content is required and must be at least 1 character.", }), -}); - -export const NoteSchema = z.object({ - id: z.string().cuid({ message: "id must be a valid CUID." }), - title: z.string().min(1, { message: "Title must be at least 1 character." }), - content: z - .string() - .min(1, { message: "Content must be at least 1 character." }), topicId: z.string().cuid({ message: "topicId must be a valid CUID." }), - userId: z.string().cuid({ message: "userId must be a valid CUID." }), - courseId: z.string().cuid({ message: "courseId must be a valid CUID." }), - createdAt: z.date({ message: "createdAt must be a valid date." }), - updatedAt: z.date({ message: "updatedAt must be a valid date." }), }); export const UpdateNoteBodySchema = z.object({ @@ -78,15 +66,10 @@ export const GetAllNotesQuerySchema = z.object({ .default(10), }); -// valitate params -export const topicIdSchema = z.object({ - topicId: z.string().trim().cuid({ message: "id must be a valid CUID." }), -}); export const noteIdSchema = z.object({ noteId: z.string().trim().cuid({ message: "id must be a valid CUID." }), }); -export type NoteServiceType = z.infer; export type UpdateNoteServiceType = z.infer; export type CreateNoteBodyType = z.infer; export type GetAllNotesQueryType = z.infer; diff --git a/apps/api/src/schemas/pagination.ts b/apps/api/src/schemas/pagination.ts new file mode 100644 index 0000000..619e645 --- /dev/null +++ b/apps/api/src/schemas/pagination.ts @@ -0,0 +1,6 @@ +export type Pagination = { + totalItems: number; + totalPages: number; + currentPage: number; + itemsPerPage: number; +}; diff --git a/apps/api/src/schemas/quizzes.ts b/apps/api/src/schemas/quizzes.ts index 226288a..7cad5ea 100644 --- a/apps/api/src/schemas/quizzes.ts +++ b/apps/api/src/schemas/quizzes.ts @@ -1,12 +1,32 @@ import z from "zod"; +export type AvailableTopic = { + topic: string; + available: { difficulty: string; count: number }[]; +}; + +export type TopicPerformance = { + topic: string; + progressByLevel: { difficulty: string; solved: number; attempted: number }[]; +}; + +export type UserQuizData = { + userTopics: AvailableTopic[]; + userProgress: TopicPerformance[]; +}; + +export const getMonthSubmissionsQuerySchema = z.object({ + month: z.coerce.number().min(1).max(12).optional(), + year: z.coerce.number().min(2000).max(2100).optional(), +}); + export const submitDailyQuizBodySchema = z.object({ answers: z .array( z.object({ questionId: z.string().uuid(), choiceIndex: z.number().int().min(0), - }) + }), ) .min(1), }); diff --git a/apps/api/src/services/ai.service.ts b/apps/api/src/services/ai.service.ts deleted file mode 100644 index f37d205..0000000 --- a/apps/api/src/services/ai.service.ts +++ /dev/null @@ -1,15 +0,0 @@ -import axios from "axios"; - -export const fetchAiRecommendation = async (quizData: any) => { - try { - const aiApiUrl = process.env.AI_API_URL || "http://localhost:5000/api/data"; - const response = await axios.post( - aiApiUrl, - quizData - ); - return response.data; - } catch (err) { - console.error("AI Recommendation error:", err); - throw new Error("Failed to get AI recommendation"); - } -}; diff --git a/apps/api/src/services/courses.ts b/apps/api/src/services/courses.ts index 065b1b0..723a463 100644 --- a/apps/api/src/services/courses.ts +++ b/apps/api/src/services/courses.ts @@ -34,14 +34,14 @@ export const getCompletedTopics = async (userId: string, courseId: string) => { }; export const getCourses = async ( - levelId: string, + levelId: string | undefined, trackId: string, userId: string, ) => { - return await prisma.course.findMany({ + const courses = await prisma.course.findMany({ where: { trackId, - ...(levelId && { levelId }), + levelId, }, orderBy: { order: "asc", @@ -58,4 +58,17 @@ export const getCourses = async ( }, }, }); + + return courses.map((course) => { + const totalTopics = course.topics.length; + const completedTopics = course.topics.filter( + (topic) => topic.userCompletions.length > 0, + ).length; + const completedPercentage = (completedTopics / totalTopics) * 100; + + return { + ...course, + completedPercentage, + }; + }); }; diff --git a/apps/api/src/services/daily-quiz.service.ts b/apps/api/src/services/daily-quiz.service.ts deleted file mode 100644 index c24acf7..0000000 --- a/apps/api/src/services/daily-quiz.service.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Local prisma client -import { prisma } from "../lib/prisma.js"; - -const startOfDay = (d: Date) => - new Date(d.getFullYear(), d.getMonth(), d.getDate()); -const nextDay = (d: Date) => - new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1); - -export const findTodayDailyQuiz = async ( - userId: string, - refDate = new Date() -) => { - return prisma.dailyQuiz.findFirst({ - where: { - userId, - createdAt: { gte: startOfDay(refDate), lt: nextDay(refDate) }, - }, - orderBy: { createdAt: "desc" }, - }); -}; - -export const createDailyQuiz = async ( - userId: string, - totalQuestions: number -) => { - return prisma.dailyQuiz.create({ - data: { userId, totalQuestions, score: 0 }, - }); -}; - -export const saveOrUpdateDailyQuiz = async ( - userId: string, - refDate: Date, - totalQuestions: number -) => { - const existing = await findTodayDailyQuiz(userId, refDate); - if (existing) { - if (existing.totalQuestions !== totalQuestions) { - return prisma.dailyQuiz.update({ - where: { id: existing.id }, - data: { totalQuestions }, - }); - } - return existing; - } - return createDailyQuiz(userId, totalQuestions); -}; - -export const submitDailyQuiz = async (quizId: string, score: number) => { - return prisma.dailyQuiz.update({ - where: { id: quizId }, - data: { score, submittedAt: new Date() }, - }); -}; - -export const updateDailyQuizScoreByUserToday = async ( - userId: string, - refDate: Date, - score: number -) => { - const quiz = await findTodayDailyQuiz(userId, refDate); - if (!quiz) return null; - return prisma.dailyQuiz.update({ - where: { id: quiz.id }, - data: { score, submittedAt: new Date() }, - }); -}; diff --git a/apps/api/src/services/notes.ts b/apps/api/src/services/notes.ts index 913eb93..3b6cd80 100644 --- a/apps/api/src/services/notes.ts +++ b/apps/api/src/services/notes.ts @@ -1,30 +1,24 @@ import { NotFoundError } from "../errors/not-found.js"; -import { Prisma } from "../generated/prisma/client.js"; +import { Note, Prisma } from "../generated/prisma/client.js"; import { prisma } from "../lib/prisma.js"; import { CreateNoteBodyType, GetAllNotesQueryType, - NoteServiceType, UpdateNoteServiceType, } from "../schemas/notes.js"; +import { Pagination } from "../schemas/pagination.js"; interface PaginatedNotesResult { - pagination: { - totalNotes: number; - totalPages: number; - currentPage: number; - limit: number; - }; - data: NoteServiceType[]; + pagination: Pagination; + data: Note[]; } export async function createNote( input: CreateNoteBodyType, - topicId: string, userId: string, -): Promise { +): Promise { const topic = await prisma.topic.findUnique({ - where: { id: topicId }, + where: { id: input.topicId }, select: { courseId: true }, }); @@ -36,7 +30,7 @@ export async function createNote( data: { title: input.title, content: input.content, - topicId, + topicId: input.topicId, userId, courseId: topic.courseId, }, @@ -48,7 +42,7 @@ export async function createNote( export async function getNoteById( noteId: string, userId: string, -): Promise { +): Promise { const note = await prisma.note.findFirst({ where: { id: noteId, @@ -67,7 +61,7 @@ export async function updateNote( noteId: string, userId: string, data: UpdateNoteServiceType, -): Promise { +): Promise { const note = await prisma.note.findFirst({ where: { id: noteId, @@ -143,10 +137,10 @@ export async function getAllFilteredNotes( return { pagination: { - totalNotes: totalCount, + totalItems: totalCount, totalPages: Math.ceil(totalCount / limit), currentPage: page, - limit, + itemsPerPage: limit, }, data: notes, }; diff --git a/apps/api/src/services/question.service.ts b/apps/api/src/services/questions.ts similarity index 64% rename from apps/api/src/services/question.service.ts rename to apps/api/src/services/questions.ts index db3f88b..0cd0cc5 100644 --- a/apps/api/src/services/question.service.ts +++ b/apps/api/src/services/questions.ts @@ -1,11 +1,11 @@ import { prisma } from "../lib/prisma.js"; export const fetchQuestionsByRecommendation = async ( - aiRecommendation: any, - totalQuestions: number + aiRecommendation: { topics: { recommendations: { level: number }[] }[] }, + totalQuestions: number, ) => { - const levelsToFetch = aiRecommendation.topics.flatMap((topic: any) => - topic.recommendations.map((rec: any) => rec.level) + const levelsToFetch = aiRecommendation.topics.flatMap((topic) => + topic.recommendations.map((rec) => rec.level), ); return prisma.question.findMany({ diff --git a/apps/api/src/services/quiz-data.service.ts b/apps/api/src/services/quiz-data.service.ts deleted file mode 100644 index a29e4a1..0000000 --- a/apps/api/src/services/quiz-data.service.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { prisma } from "../lib/prisma.js"; - -export const buildUserQuizData = async ( - userId: string, - totalQuestions: number -) => { - const user = await prisma.user.findUnique({ - where: { id: userId }, - include: { - userCompletions: { - include: { - topic: { - include: { - course: { include: { level: true } }, - questions: true, - }, - }, - }, - }, - }, - }); - - const userCompletions = user?.userCompletions; - if (!userCompletions || userCompletions.length === 0) { - return null; - } - - const topicMap = new Map< - string, - { topic: string; available: { level: number; count: number }[] } - >(); - const progressMap = new Map< - string, - { - topic: string; - progressByLevel: { level: number; solved: number; attempted: number }[]; - } - >(); - - for (const completion of userCompletions) { - const topic = completion.topic; - const course = topic.course; - const levelNumber = parseInt(course.level.title.match(/\d+/)?.[0] || "0"); - - // Build userTopics - if (!topicMap.has(topic.title)) { - topicMap.set(topic.title, { - topic: topic.title, - available: [{ level: levelNumber, count: topic.questions.length }], - }); - } else { - const available = topicMap.get(topic.title)!.available; - const levelEntry = available.find((a) => a.level === levelNumber); - if (levelEntry) { - levelEntry.count += topic.questions.length; - } else { - available.push({ level: levelNumber, count: topic.questions.length }); - } - } - - // Build userProgress - if (!progressMap.has(topic.title)) { - progressMap.set(topic.title, { - topic: topic.title, - progressByLevel: [ - { - level: levelNumber, - solved: topic.solved, - attempted: topic.attempted, - }, - ], - }); - } else { - const progressByLevel = progressMap.get(topic.title)!.progressByLevel; - const progressEntry = progressByLevel.find( - (p) => p.level === levelNumber - ); - if (progressEntry) { - progressEntry.solved += topic.solved; - progressEntry.attempted += topic.attempted; - } else { - progressByLevel.push({ - level: levelNumber, - solved: topic.solved, - attempted: topic.attempted, - }); - } - } - } - - return { - userId, - totalQuestions, - userTopics: Array.from(topicMap.values()), - userProgress: Array.from(progressMap.values()), - }; -}; diff --git a/apps/api/src/services/quiz-submission.service.ts b/apps/api/src/services/quiz-submission.service.ts deleted file mode 100644 index f957198..0000000 --- a/apps/api/src/services/quiz-submission.service.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { prisma } from "../lib/prisma.js"; - -export interface SubmittedAnswerInput { - questionId: string; - choiceIndex: number; -} - -export interface GradedAnswerResult { - questionId: string; - userChoiceIndex: number | null; - correctOptionIndex: number; - isCorrect: boolean; -} - -export interface GradeQuizResult { - graded: GradedAnswerResult[]; - correctCount: number; - total: number; - scorePercentage: number; // 0-100 -} - -const INVALID_QUESTION_INDEX = -1; - -/** - * Grades a batch of answers. Missing or invalid questions are ignored but still counted toward total if they existed in input. - */ -export const gradeAnswers = async ( - answers: SubmittedAnswerInput[] -): Promise => { - // Dedupe by questionId (keep last answer provided by user) - const map = new Map(); - for (const a of answers) { - if (a && a.questionId) map.set(a.questionId, a); - } - const uniqueAnswers = Array.from(map.values()); - if (uniqueAnswers.length === 0) { - return { graded: [], correctCount: 0, total: 0, scorePercentage: 0 }; - } - - const questionIds = uniqueAnswers.map((a) => a.questionId); - const questions = await prisma.question.findMany({ - where: { id: { in: questionIds } }, - select: { id: true, correctOptionIndex: true }, - }); - const questionMap = new Map< - string, - { id: string; correctOptionIndex: number } - >( - questions.map((q: { id: string; correctOptionIndex: number }) => [q.id, q]) - ); - - const graded: GradedAnswerResult[] = uniqueAnswers.map((a) => { - const q = questionMap.get(a.questionId); - if (!q) { - return { - questionId: a.questionId, - userChoiceIndex: a.choiceIndex ?? null, - correctOptionIndex: INVALID_QUESTION_INDEX, - isCorrect: false, - }; - } - const isCorrect = a.choiceIndex === q.correctOptionIndex; - return { - questionId: q.id, - userChoiceIndex: a.choiceIndex ?? null, - correctOptionIndex: q.correctOptionIndex, - isCorrect, - }; - }); - - const correctCount = graded.filter((g) => g.isCorrect).length; - const total = graded.length; - const scorePercentage = - total === 0 ? 0 : +((correctCount / total) * 100).toFixed(2); - - return { graded, correctCount, total, scorePercentage }; -}; diff --git a/apps/api/src/services/quizzes.ts b/apps/api/src/services/quizzes.ts new file mode 100644 index 0000000..c52c5a8 --- /dev/null +++ b/apps/api/src/services/quizzes.ts @@ -0,0 +1,182 @@ +import { getMonthInterval, getStartOfDay } from "../helpers/dates.js"; +import { prisma } from "../lib/prisma.js"; +import { AvailableTopic, TopicPerformance } from "../schemas/quizzes.js"; + +export interface SubmittedAnswerInput { + questionId: string; + choiceIndex: number; +} + +export interface GradedAnswerResult { + questionId: string; + userChoiceIndex: number | null; + correctOptionIndex: number; + isCorrect: boolean; +} + +export interface GradeQuizResult { + graded: GradedAnswerResult[]; + correctCount: number; + total: number; + scorePercentage: number; // 0-100 +} + +const INVALID_QUESTION_INDEX = -1; + +export const getMonthSubmissions = async ( + month: number, + year: number, + userId: string, +) => { + const { start, end } = getMonthInterval(month, year); + const submissions = await prisma.dailyQuiz.findMany({ + where: { + userId, + submittedAt: { not: null, gte: start, lt: end }, + }, + select: { submittedAt: true }, + }); + const days = Array.from({ length: 31 }).fill(false); // 31 days max + for (const submission of submissions) { + if (!submission.submittedAt) continue; + const day = submission.submittedAt.getDate(); // 1-based + if (day >= 1 && day <= 31) days[day - 1] = true; + } + return days; +}; + +export const getQuizByDate = async (userId: string, refDate = new Date()) => { + return prisma.dailyQuiz.findFirst({ + where: { + userId, + quizDate: getStartOfDay(refDate), + }, + }); +}; + +export const buildUserQuizData = async ( + userId: string, + totalQuestions: number, +) => { + const topics = await prisma.topic.findMany({ + where: { + userCompletions: { some: { userId } }, + }, + select: { + id: true, + userPerformances: { where: { userId } }, + }, + }); + + if (!topics || topics.length === 0) { + return null; + } + + const userTopics: AvailableTopic[] = []; + const userProgress: TopicPerformance[] = []; + + for (const topic of topics) { + const questionsCount = await prisma.question.groupBy({ + where: { topics: { some: { id: topic.id } } }, + _count: { id: true }, + by: ["difficulty"], + }); + + userTopics.push({ + topic: topic.id, + available: questionsCount.map((value) => ({ + difficulty: value.difficulty, + count: value._count.id, + })), + }); + + userProgress.push({ + topic: topic.id, + progressByLevel: topic.userPerformances, + }); + } + + return { + userId, + totalQuestions, + userTopics, + userProgress, + }; +}; + +export const createDailyQuiz = async ( + userId: string, + totalQuestions: number, +) => { + return prisma.dailyQuiz.create({ + data: { + userId, + totalQuestions, + score: 0, + quizDate: getStartOfDay(new Date()), + }, + }); +}; + +export const submitDailyQuiz = async (userId: string, score: number) => { + const day = getStartOfDay(new Date()); + return prisma.dailyQuiz.updateMany({ + where: { userId, quizDate: day }, + data: { score, submittedAt: new Date() }, + }); +}; + +/** + * Grades a batch of answers. Missing or invalid questions are ignored but still counted toward total if they existed in input. + */ +export const gradeAnswers = async ( + answers: SubmittedAnswerInput[], +): Promise => { + // Dedupe by questionId (keep last answer provided by user) + const map = new Map(); + for (const a of answers) { + if (a && a.questionId) map.set(a.questionId, a); + } + const uniqueAnswers = [...map.values()]; + if (uniqueAnswers.length === 0) { + return { graded: [], correctCount: 0, total: 0, scorePercentage: 0 }; + } + + const questionIds = uniqueAnswers.map((a) => a.questionId); + const questions = await prisma.question.findMany({ + where: { id: { in: questionIds } }, + select: { id: true, correctOptionIndex: true }, + }); + const questionMap = new Map< + string, + { id: string; correctOptionIndex: number } + >( + questions.map((q: { id: string; correctOptionIndex: number }) => [q.id, q]), + ); + + const graded: GradedAnswerResult[] = uniqueAnswers.map((a) => { + const q = questionMap.get(a.questionId); + if (!q) { + return { + questionId: a.questionId, + userChoiceIndex: a.choiceIndex ?? null, + correctOptionIndex: INVALID_QUESTION_INDEX, + isCorrect: false, + }; + } + const isCorrect = a.choiceIndex === q.correctOptionIndex; + return { + questionId: q.id, + userChoiceIndex: a.choiceIndex ?? null, + correctOptionIndex: q.correctOptionIndex, + isCorrect, + }; + }); + + const correctCount = graded.filter((g) => g.isCorrect).length; + const total = graded.length; + const scorePercentage = + total === 0 ? 0 : +((correctCount / total) * 100).toFixed(2); + + return { graded, correctCount, total, scorePercentage }; +}; diff --git a/apps/api/src/services/recommendations.ts b/apps/api/src/services/recommendations.ts new file mode 100644 index 0000000..bc2463b --- /dev/null +++ b/apps/api/src/services/recommendations.ts @@ -0,0 +1,13 @@ +import axios from "axios"; +import { UserQuizData } from "../schemas/quizzes.js"; + +export const fetchAiRecommendation = async (quizData: UserQuizData) => { + try { + const aiApiUrl = process.env.AI_API_URL || "http://localhost:5000/api/data"; + const response = await axios.post(aiApiUrl, quizData); + return response.data; + } catch (error) { + console.error("AI Recommendation error:", error); + throw new Error("Failed to get AI recommendation"); + } +}; diff --git a/apps/api/src/services/topics.ts b/apps/api/src/services/topics.ts index c42b5c0..cafe473 100644 --- a/apps/api/src/services/topics.ts +++ b/apps/api/src/services/topics.ts @@ -1,3 +1,4 @@ +import { Prisma } from "../generated/prisma/client.js"; import { prisma } from "../lib/prisma.js"; export const getTopic = async (topicId: string) => { @@ -13,7 +14,7 @@ export const getTopic = async (topicId: string) => { }); }; -export const completeTopic = async (userId: string, topicId: string) => { +export const markTopicAsCompleted = async (userId: string, topicId: string) => { return await prisma.userCompletion.create({ data: { userId, @@ -21,24 +22,39 @@ export const completeTopic = async (userId: string, topicId: string) => { }, }); }; -export const inCompleteTopic = async (userId: string, topicId: string) => { - prisma.userCompletion.delete({ +export const unmarkTopicAsCompleted = async ( + userId: string, + topicId: string, +) => { + return await prisma.userCompletion.delete({ where: { userId_topicId: { userId, topicId }, }, }); }; -export const getToltalTopics = async (courseId: string) => { +export const getToltalTopics = async ( + courseId: string, + completion?: { isCompleted: boolean; userId: string }, +) => { + const whereClause: Prisma.TopicWhereInput = { courseId }; + if (completion?.isCompleted === true) { + whereClause.userCompletions = { + some: { userId: completion.userId }, + }; + } else if (completion?.isCompleted === false) { + whereClause.userCompletions = { + none: { userId: completion?.userId }, + }; + } return await prisma.topic.findMany({ - where: { - courseId, - }, + where: whereClause, orderBy: { order: "asc", }, }); }; + export const getCompletedTopics = async (userId: string, courseId: string) => { return await prisma.userCompletion.findMany({ where: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86d32e0..47e1e85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: dependencies: '@adminjs/express': specifier: ^6.1.1 - version: 6.1.1(adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8))(express-formidable@1.2.0)(express-session@1.18.2)(express@4.21.2)(tslib@2.8.1) + version: 6.1.1(adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8))(express-formidable@1.2.0)(express-session@1.18.2)(express@4.21.2)(tslib@2.8.1) '@prisma/client': specifier: 6.11.1 version: 6.11.1(prisma@6.12.0(typescript@5.8.2))(typescript@5.8.2) @@ -34,7 +34,7 @@ importers: version: 2.1.13(@tiptap/core@2.1.13(@tiptap/pm@2.1.13))(@tiptap/pm@2.1.13) adminjs: specifier: ^7.8.17 - version: 7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8) + version: 7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8) axios: specifier: ^1.11.0 version: 1.11.0 @@ -54,8 +54,8 @@ importers: specifier: ^3.1.1 version: 3.1.1(express@4.21.2) zod: - specifier: ^3.24.3 - version: 3.25.76 + specifier: ^4.0.17 + version: 4.0.17 devDependencies: '@better-auth/cli': specifier: ^1.2.12 @@ -176,7 +176,7 @@ importers: version: 9.32.0 '@tailwindcss/vite': specifier: ^4.1.5 - version: 4.1.11(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) + version: 4.1.11(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 @@ -197,7 +197,7 @@ importers: version: 19.1.6(@types/react@19.1.8) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) + version: 4.7.0(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) @@ -236,16 +236,16 @@ importers: version: 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.7.3) vite: specifier: ^6.3.1 - version: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) + version: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) vite-plugin-svgr: specifier: ^4.3.0 - version: 4.3.0(rollup@4.40.2)(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) + version: 4.3.0(rollup@4.40.2)(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) + version: 5.1.4(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) vitest: specifier: ^3.1.2 - version: 3.2.4(@types/node@22.16.5)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) + version: 3.2.4(@types/node@22.16.5)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3) packages: @@ -5639,8 +5639,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.0.10: - resolution: {integrity: sha512-3vB+UU3/VmLL2lvwcY/4RV2i9z/YU0DTV/tDuYjrwmx5WeJ7hwy+rGEEx8glHp6Yxw7ibRbKSaIFBgReRPe5KA==} + zod@4.0.17: + resolution: {integrity: sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==} snapshots: @@ -5691,9 +5691,9 @@ snapshots: - react-is - supports-color - '@adminjs/express@6.1.1(adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8))(express-formidable@1.2.0)(express-session@1.18.2)(express@4.21.2)(tslib@2.8.1)': + '@adminjs/express@6.1.1(adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8))(express-formidable@1.2.0)(express-session@1.18.2)(express@4.21.2)(tslib@2.8.1)': dependencies: - adminjs: 7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8) + adminjs: 7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8) express: 4.21.2 express-formidable: 1.2.0 express-session: 1.18.2 @@ -6966,7 +6966,7 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@hello-pangea/dnd@16.6.0(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@hello-pangea/dnd@16.6.0(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.2 css-box-model: 1.2.1 @@ -6974,7 +6974,7 @@ snapshots: raf-schd: 4.0.3 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-redux: 8.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) + react-redux: 8.1.3(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) redux: 4.2.1 use-memo-one: 1.1.3(react@18.3.1) transitivePeerDependencies: @@ -7739,12 +7739,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.11 '@tailwindcss/oxide-win32-x64-msvc': 4.1.11 - '@tailwindcss/vite@4.1.11(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2))': + '@tailwindcss/vite@4.1.11(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3))': dependencies: '@tailwindcss/node': 4.1.11 '@tailwindcss/oxide': 4.1.11 tailwindcss: 4.1.11 - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) '@tanstack/query-core@5.83.0': {} @@ -8285,7 +8285,7 @@ snapshots: '@typescript-eslint/types': 8.38.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react@4.7.0(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2))': + '@vitejs/plugin-react@4.7.0(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0) @@ -8293,7 +8293,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) transitivePeerDependencies: - supports-color @@ -8305,13 +8305,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2))': + '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) '@vitest/pretty-format@3.2.4': dependencies: @@ -8352,7 +8352,7 @@ snapshots: acorn@8.15.0: {} - adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8): + adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8): dependencies: '@adminjs/design-system': 4.1.1(@babel/core@7.28.0)(@types/react@19.1.8)(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1) '@babel/core': 7.28.0 @@ -8363,7 +8363,7 @@ snapshots: '@babel/preset-react': 7.27.1(@babel/core@7.28.0) '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0) '@babel/register': 7.27.1(@babel/core@7.28.0) - '@hello-pangea/dnd': 16.6.0(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@hello-pangea/dnd': 16.6.0(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@redux-devtools/extension': 3.3.0(redux@4.2.1) '@rollup/plugin-babel': 6.0.4(@babel/core@7.28.0)(@types/babel__core@7.20.5)(rollup@4.40.2) '@rollup/plugin-commonjs': 25.0.8(rollup@4.40.2) @@ -8386,7 +8386,7 @@ snapshots: react-feather: 2.0.10(react@18.3.1) react-i18next: 12.3.1(i18next@22.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-is: 18.3.1 - react-redux: 8.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) + react-redux: 8.1.3(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) react-router: 6.30.1(react@18.3.1) react-router-dom: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) redux: 4.2.1 @@ -8565,7 +8565,7 @@ snapshots: jose: 5.10.0 kysely: 0.28.2 nanostores: 0.11.4 - zod: 4.0.10 + zod: 4.0.17 optionalDependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -10604,7 +10604,7 @@ snapshots: react-fast-compare: 3.2.2 warning: 4.0.3 - react-redux@8.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1): + react-redux@8.1.3(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1): dependencies: '@babel/runtime': 7.28.2 '@types/hoist-non-react-statics': 3.3.7(@types/react@19.1.8) @@ -10615,7 +10615,6 @@ snapshots: use-sync-external-store: 1.5.0(react@18.3.1) optionalDependencies: '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) react-dom: 18.3.1(react@18.3.1) redux: 4.2.1 @@ -11428,13 +11427,13 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2): + vite-node@3.2.4(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3): dependencies: cac: 6.7.14 debug: 4.4.1(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) transitivePeerDependencies: - '@types/node' - jiti @@ -11449,29 +11448,29 @@ snapshots: - tsx - yaml - vite-plugin-svgr@4.3.0(rollup@4.40.2)(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)): + vite-plugin-svgr@4.3.0(rollup@4.40.2)(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)): dependencies: '@rollup/pluginutils': 5.1.4(rollup@4.40.2) '@svgr/core': 8.1.0(typescript@5.7.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.7.3)) - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) transitivePeerDependencies: - rollup - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)): + vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)): dependencies: debug: 4.4.0 globrex: 0.1.2 tsconfck: 3.1.5(typescript@5.7.3) optionalDependencies: - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) transitivePeerDependencies: - supports-color - typescript - vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2): + vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3): dependencies: esbuild: 0.25.4 fdir: 6.4.6(picomatch@4.0.3) @@ -11485,13 +11484,12 @@ snapshots: jiti: 2.5.1 lightningcss: 1.30.1 tsx: 4.20.3 - yaml: 1.10.2 - vitest@3.2.4(@types/node@22.16.5)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2): + vitest@3.2.4(@types/node@22.16.5)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) + '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -11509,8 +11507,8 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) - vite-node: 3.2.4(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite-node: 3.2.4(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.16.5 @@ -11648,4 +11646,4 @@ snapshots: zod@3.25.76: {} - zod@4.0.10: {} + zod@4.0.17: {} From 28e91c07895aa5c7c014145f4e05de0287d14bb0 Mon Sep 17 00:00:00 2001 From: fouadhassan74 Date: Mon, 1 Sep 2025 06:51:21 +0300 Subject: [PATCH 29/31] apply sercvices --- apps/api/eslint.config.mjs | 21 ++-- apps/api/pnpm | 0 apps/web/package.json | 5 +- apps/web/src/components/login/LoginForm.tsx | 3 +- apps/web/src/config/axiosInstance.ts | 76 +++++++++++++ apps/web/src/hooks/courseHooks.ts | 20 ++++ apps/web/src/hooks/notesHooks.ts | 59 ++++++++++ apps/web/src/hooks/quizHooks.ts | 41 +++++++ apps/web/src/hooks/topicHooks.ts | 38 +++++++ apps/web/src/hooks/trackHooks.ts | 19 ++++ apps/web/src/lib/Authenticator.tsx | 31 ++++++ apps/web/src/lib/auth-client.ts | 10 ++ apps/web/src/routes/AppRouter.tsx | 8 +- apps/web/src/services/authService.tsx | 23 ++++ apps/web/src/services/coursesService.tsx | 42 ++++++++ apps/web/src/services/notesService.tsx | 97 +++++++++++++++++ apps/web/src/services/quizesService.tsx | 113 ++++++++++++++++++++ apps/web/src/services/topicsService.tsx | 49 +++++++++ apps/web/src/services/tracksService.tsx | 42 ++++++++ pnpm-lock.yaml | 106 ++++++++++++------ 20 files changed, 756 insertions(+), 47 deletions(-) create mode 100644 apps/api/pnpm create mode 100644 apps/web/src/config/axiosInstance.ts create mode 100644 apps/web/src/hooks/courseHooks.ts create mode 100644 apps/web/src/hooks/notesHooks.ts create mode 100644 apps/web/src/hooks/quizHooks.ts create mode 100644 apps/web/src/hooks/topicHooks.ts create mode 100644 apps/web/src/hooks/trackHooks.ts create mode 100644 apps/web/src/lib/Authenticator.tsx create mode 100644 apps/web/src/lib/auth-client.ts create mode 100644 apps/web/src/services/authService.tsx create mode 100644 apps/web/src/services/coursesService.tsx create mode 100644 apps/web/src/services/notesService.tsx create mode 100644 apps/web/src/services/quizesService.tsx create mode 100644 apps/web/src/services/topicsService.tsx create mode 100644 apps/web/src/services/tracksService.tsx diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs index 6d12476..876a3fe 100644 --- a/apps/api/eslint.config.mjs +++ b/apps/api/eslint.config.mjs @@ -1,21 +1,20 @@ import globals from "globals"; import jsPlugin from "@eslint/js"; import tsPlugin from "typescript-eslint"; -import unicornPlugin from 'eslint-plugin-unicorn'; -import prettierConfig from 'eslint-config-prettier'; -import prettierPluginRecommended from 'eslint-plugin-prettier/recommended'; -import sonarjs from 'eslint-plugin-sonarjs'; - +import unicornPlugin from "eslint-plugin-unicorn"; +// import prettierConfig from "eslint-config-prettier"; +// import prettierPluginRecommended from "eslint-plugin-prettier/recommended"; +import sonarjs from "eslint-plugin-sonarjs"; /** @type {import('eslint').Linter.Config[]} */ export default [ { - ignores: ["**/seed/**", "**/generated/**"] + ignores: ["**/seed/**", "**/generated/**"], }, jsPlugin.configs.recommended, - unicornPlugin.configs['recommended'], - prettierPluginRecommended, - prettierConfig, + unicornPlugin.configs["recommended"], + // prettierPluginRecommended, + // prettierConfig, sonarjs.configs.recommended, ...tsPlugin.configs.recommended, { @@ -33,6 +32,6 @@ export default [ }, languageOptions: { globals: globals.node, - } - } + }, + }, ]; diff --git a/apps/api/pnpm b/apps/api/pnpm new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/package.json b/apps/web/package.json index e935ccd..2f4ec2a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,9 +21,11 @@ "@radix-ui/react-tabs": "^1.1.12", "@tanstack/react-query": "^5.74.11", "@tanstack/react-query-devtools": "^5.74.11", + "better-auth": "^1.2.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "js-cookie": "^3.0.5", "lucide-react": "^0.503.0", "react": "^19.0.0", "react-day-picker": "8.10.1", @@ -41,6 +43,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/js-cookie": "^3.0.6", "@types/node": "^22.15.3", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", @@ -62,4 +65,4 @@ "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.1.2" } -} \ No newline at end of file +} diff --git a/apps/web/src/components/login/LoginForm.tsx b/apps/web/src/components/login/LoginForm.tsx index 091be60..44fe76b 100644 --- a/apps/web/src/components/login/LoginForm.tsx +++ b/apps/web/src/components/login/LoginForm.tsx @@ -13,6 +13,7 @@ import { Label } from "@components/ui/label"; import { Checkbox } from "@components/ui/checkbox"; import { User, Lock } from "lucide-react"; import { loginSchema } from "@/src/validation/loginSchema"; +import { login } from "../../services/authService"; type FormValues = { username: string; @@ -42,7 +43,7 @@ function LoginForm() { }); const onSubmit = (data: FormValues) => { - console.log("Form Data:", data); + login(data.username, data.password); form.reset(); }; diff --git a/apps/web/src/config/axiosInstance.ts b/apps/web/src/config/axiosInstance.ts new file mode 100644 index 0000000..873c137 --- /dev/null +++ b/apps/web/src/config/axiosInstance.ts @@ -0,0 +1,76 @@ +import axios from "axios"; +import Cookies from "js-cookie"; + +// Ensure that the environment variable is set and valid +const URL = import.meta.env.VITE_API_URL; +if (!URL) { + console.error( + "API URL is not defined. Please check your environment variables.", + ); +} + +const config = { + maxBodyLength: 10 * 1024 * 1024, // Set to 10MB, adjust as needed + baseURL: URL, + headers: { + Accept: "application/json", + }, +}; + +// Create an axios instance with the defined configuration +const axiosInstance = axios.create(config); + +// Request interceptor to attach authorization token +axiosInstance.interceptors.request.use( + (request) => { + const token = Cookies.get("token"); + + // Attach the authorization token if available + if (token) { + request.headers["Authorization"] = `Bearer ${token}`; + } + + return request; + }, + (error) => { + console.error("Request error:", error); // Log request error + return Promise.reject(error); + }, +); + +// Response interceptor to handle responses and errors +axiosInstance.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + if (error.response) { + // Handle specific error responses + console.error("API Error:", error.response.data); + switch (error.response.status) { + case 401: + // Handle unauthorized access, e.g., redirect to login + console.warn("Unauthorized access - redirecting to login."); + // Optionally, you could use a history.push or navigate to redirect + break; + case 403: + // Handle forbidden access + console.warn("Access forbidden - insufficient permissions."); + break; + case 500: + // Handle internal server errors + console.error("Internal server error - please try again later."); + break; + default: + console.error("An unexpected error occurred."); + } + } else { + // Handle errors without a response (network error, etc.) + console.error("Network error:", error.message); + } + + return Promise.reject(error); + }, +); + +export default axiosInstance; diff --git a/apps/web/src/hooks/courseHooks.ts b/apps/web/src/hooks/courseHooks.ts new file mode 100644 index 0000000..fbd4999 --- /dev/null +++ b/apps/web/src/hooks/courseHooks.ts @@ -0,0 +1,20 @@ +// src/hooks/courseHooks.ts +import { useQuery } from "@tanstack/react-query"; +import { courseService, Course } from "../services/coursesService"; + +// ================== useCoursesList ================== +export const useCoursesList = () => { + return useQuery({ + queryKey: ["courses"], + queryFn: courseService.getCoursesList, + }); +}; + +// ================== useCourseDetails ================== +export const useCourseDetails = (courseId: string) => { + return useQuery({ + queryKey: ["courseDetails", courseId], + queryFn: () => courseService.getCourseDetails(courseId), + enabled: !!courseId, + }); +}; diff --git a/apps/web/src/hooks/notesHooks.ts b/apps/web/src/hooks/notesHooks.ts new file mode 100644 index 0000000..7e16bce --- /dev/null +++ b/apps/web/src/hooks/notesHooks.ts @@ -0,0 +1,59 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { userService, NotesListParams } from "../services/notesService"; + +// 🟢 Get Notes List +export const useNotesList = (params: NotesListParams) => { + return useQuery({ + queryKey: ["notes", params], + queryFn: () => userService.getNotesList(params), + }); +}; + +// 🟢 Get Note Details +export const useNoteDetails = (noteId: string) => { + return useQuery({ + queryKey: ["note", noteId], + queryFn: () => userService.getNoteDetails(noteId), + enabled: !!noteId, + }); +}; + +// 🟢 Create Note +export const useCreateNote = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: userService.createNote, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +}; + +// 🟢 Update Note +export const useUpdateNote = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + noteId, + updatedNote, + }: { + noteId: string; + updatedNote: { title?: string; content?: string }; + }) => userService.updateNote(noteId, updatedNote), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ["notes"] }); + queryClient.invalidateQueries({ queryKey: ["note", data.id] }); + }, + }); +}; + +// 🟢 Delete Note +export const useDeleteNote = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (noteId: string) => userService.deleteNote(noteId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +}; diff --git a/apps/web/src/hooks/quizHooks.ts b/apps/web/src/hooks/quizHooks.ts new file mode 100644 index 0000000..de8e62f --- /dev/null +++ b/apps/web/src/hooks/quizHooks.ts @@ -0,0 +1,41 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + quizService, + QuizCalendar, + DailyQuizResponse, + QuizSubmitRequest, + QuizSubmitResponse, +} from "../services/quizesService"; + +// ================== useQuizCalendar ================== +export const useQuizCalendar = (courseId: string) => { + return useQuery({ + queryKey: ["quizCalendar", courseId], + queryFn: () => quizService.getQuizCalendar(courseId), + enabled: !!courseId, + }); +}; + +// ================== useDailyQuiz ================== +export const useDailyQuiz = (courseId: string) => { + return useQuery({ + queryKey: ["dailyQuiz", courseId], + queryFn: () => quizService.getDailyQuiz(courseId), + enabled: !!courseId, + }); +}; + +// ================== useSubmitDailyQuiz ================== +export const useSubmitDailyQuiz = (courseId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (answers: QuizSubmitRequest) => + quizService.submitDailyQuiz(courseId, answers), + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["quizCalendar", courseId] }); + queryClient.invalidateQueries({ queryKey: ["dailyQuiz", courseId] }); + }, + }); +}; diff --git a/apps/web/src/hooks/topicHooks.ts b/apps/web/src/hooks/topicHooks.ts new file mode 100644 index 0000000..2f9911b --- /dev/null +++ b/apps/web/src/hooks/topicHooks.ts @@ -0,0 +1,38 @@ +// src/hooks/useTopics.ts +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { topicsService } from "../services/topicsService"; + +// 🟢 Get Topic by ID +export const useTopicById = (topicId: string) => { + return useQuery({ + queryKey: ["topic", topicId], + queryFn: () => topicsService.getTopicById(topicId), + enabled: !!topicId, + }); +}; + +// 🟢 Mark Topic as Completed +export const useMarkTopicAsCompleted = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (topicId: string) => + topicsService.markTopicAsCompleted(topicId), + onSuccess: (_, topicId) => { + queryClient.invalidateQueries({ queryKey: ["topic", topicId] }); + }, + }); +}; + +// 🟢 Unmark Topic as Completed +export const useUnmarkTopicAsCompleted = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (topicId: string) => + topicsService.unMarkTopicAsCompleted(topicId), + onSuccess: (_, topicId) => { + queryClient.invalidateQueries({ queryKey: ["topic", topicId] }); + }, + }); +}; diff --git a/apps/web/src/hooks/trackHooks.ts b/apps/web/src/hooks/trackHooks.ts new file mode 100644 index 0000000..fea6177 --- /dev/null +++ b/apps/web/src/hooks/trackHooks.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import { trackService, Track } from "../services/tracksService"; + +// 🔹 Hook: Get all tracks list +export const useTracksList = () => { + return useQuery({ + queryKey: ["tracks"], + queryFn: () => trackService.getTracksList(), + }); +}; + +// 🔹 Hook: Get track details by ID +export const useTrackDetails = (trackId: string) => { + return useQuery({ + queryKey: ["track", trackId], + queryFn: () => trackService.getTrackDetails(trackId), + enabled: !!trackId, // only fetch if trackId is provided + }); +}; diff --git a/apps/web/src/lib/Authenticator.tsx b/apps/web/src/lib/Authenticator.tsx new file mode 100644 index 0000000..0f0d558 --- /dev/null +++ b/apps/web/src/lib/Authenticator.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import Cookies from "js-cookie"; +import { Navigate, useLocation } from "react-router-dom"; + +interface AuthenticatorProps { + children: React.ReactNode; +} + +const Authenticator: React.FC = ({ children }) => { + const location = useLocation(); + const token = Cookies.get("token"); + + const currentPath = location.pathname; + const isOnAuthPage = currentPath.startsWith("/login"); + + if (token) { + // If user is authenticated and tries to access auth pages, redirect to home + if (isOnAuthPage) { + return ; + } + } else { + // If not authenticated and NOT on auth page, redirect to landing/login + if (!isOnAuthPage) { + return ; + } + } + + return <>{children}; +}; + +export default Authenticator; diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts new file mode 100644 index 0000000..5786fc1 --- /dev/null +++ b/apps/web/src/lib/auth-client.ts @@ -0,0 +1,10 @@ +// authClient.ts +import { createAuthClient } from "better-auth/react"; +const URL = import.meta.env.VITE_API_URL; + +export const authClient = createAuthClient({ + baseURL: URL, + fetchOptions: { + credentials: "include", + }, +}); diff --git a/apps/web/src/routes/AppRouter.tsx b/apps/web/src/routes/AppRouter.tsx index 25f5cae..d7bc2f0 100644 --- a/apps/web/src/routes/AppRouter.tsx +++ b/apps/web/src/routes/AppRouter.tsx @@ -1,5 +1,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import Authenticator from "../lib/Authenticator"; + //layouts import MainLayout from "@layouts/MainLayout"; @@ -31,7 +33,11 @@ const router = createBrowserRouter([ }, { path: "/", - element: , + element: ( + + + + ), children: [ { index: true, diff --git a/apps/web/src/services/authService.tsx b/apps/web/src/services/authService.tsx new file mode 100644 index 0000000..814c12b --- /dev/null +++ b/apps/web/src/services/authService.tsx @@ -0,0 +1,23 @@ +import { authClient } from "../lib/auth-client"; + +async function login(email: string, password: string) { + try { + const res = await authClient.signIn.email({ email, password }); + console.log("Logged in user:", res); + return res; + } catch (err) { + console.error("Login failed:", err); + } +} + +async function getProfile() { + try { + const res = await authClient.getSession(); + console.log("User profile:", res); + return res; + } catch (err) { + console.error("Fetch user failed:", err); + } +} + +export { login, getProfile }; diff --git a/apps/web/src/services/coursesService.tsx b/apps/web/src/services/coursesService.tsx new file mode 100644 index 0000000..672e7f5 --- /dev/null +++ b/apps/web/src/services/coursesService.tsx @@ -0,0 +1,42 @@ +import axiosInstance from "../config/axiosInstance"; + +export interface Topic { + id: string; + name: string; + content: string; +} + +export interface Course { + id: string; + name: string; + description: string; + level: string; + topics: Topic[]; + completedPercentage: number; +} + +export const courseService = { + // 🟢 Get all courses + getCoursesList: async (): Promise => { + const { data } = await axiosInstance.get<{ + success: boolean; + statusCode: number; + timestamp: string; + data: Course[]; + }>("/courses"); + + return data.data; // ✅ unwrap the data + }, + + // 🟢 Get single course details + getCourseDetails: async (courseId: string): Promise => { + const { data } = await axiosInstance.get<{ + success: boolean; + statusCode: number; + timestamp: string; + data: Course; + }>(`/courses/${courseId}`); + + return data.data; // ✅ unwrap the data + }, +}; diff --git a/apps/web/src/services/notesService.tsx b/apps/web/src/services/notesService.tsx new file mode 100644 index 0000000..07d0e85 --- /dev/null +++ b/apps/web/src/services/notesService.tsx @@ -0,0 +1,97 @@ +import axiosInstance from "../config/axiosInstance"; + +export interface Note { + id: string; + title: string; + content: string; + topicId: string; + userId: string; // موجود في schema + createdAt: string; + updatedAt: string; +} + +export interface NotesListParams { + courseId?: string; + topicId?: string; + search?: string; + sort?: string; + page?: number; + limit?: number; +} + +export interface Pagination { + totalItems: number; + totalPages: number; + currentPage: number; + itemsPerPage: number; +} + +export interface ApiResponse { + success: boolean; + statusCode: number; + timestamp: string; + data: T; + pagination?: Pagination; +} + +export const userService = { + // 🟢 Get list + getNotesList: async ({ + courseId = "", + topicId = "", + search = "", + sort = "createdAt", + page = 1, + limit = 10, + }: NotesListParams): Promise<{ notes: Note[]; pagination: Pagination }> => { + const { data } = await axiosInstance.get>("/notes", { + params: { courseId, topicId, search, sort, page, limit }, + }); + + return { + notes: data.data, + pagination: data.pagination!, + }; + }, + + // 🟢 Get details + getNoteDetails: async (noteId: string): Promise => { + const { data } = await axiosInstance.get>( + `/notes/${noteId}`, + ); + return data.data; + }, + + // 🟢 Create + createNote: async (newNote: { + title: string; + content: string; + topicId: string; + }): Promise => { + const { data } = await axiosInstance.post>( + "/notes", + newNote, + ); + return data.data; + }, + + // 🟢 Update + updateNote: async ( + noteId: string, + updatedNote: { title?: string; content?: string }, + ): Promise => { + const { data } = await axiosInstance.put>( + `/notes/${noteId}`, + updatedNote, + ); + return data.data; + }, + + // 🟢 Delete + deleteNote: async (noteId: string): Promise<{ success: boolean }> => { + const { data } = await axiosInstance.delete< + ApiResponse<{ success: boolean }> + >(`/notes/${noteId}`); + return data.data; + }, +}; diff --git a/apps/web/src/services/quizesService.tsx b/apps/web/src/services/quizesService.tsx new file mode 100644 index 0000000..a8033c4 --- /dev/null +++ b/apps/web/src/services/quizesService.tsx @@ -0,0 +1,113 @@ +import axiosInstance from "../config/axiosInstance"; + +// ✅ Shared API Response Wrapper +export interface ApiResponse { + success: boolean; + statusCode: number; + timestamp: string; + data: T; +} + +// ================== Calendar ================== +export interface QuizCalendarDay { + day: number; + hasSubmission: boolean; + score: number | null; +} + +export interface QuizCalendar { + year: number; + month: number; + days: QuizCalendarDay[]; +} + +// ================== Daily Quiz ================== +export interface QuizInfo { + id: string; + userId: string; + date: string; + totalQuestions: number; + submittedAt: string | null; + score: number | null; +} + +export interface QuizQuestion { + id: string; + question: string; + choices: string[]; + difficulty: string; + topic: string; +} + +export interface QuizRecommendationTopic { + topic: string; + difficulty: string; + count: number; +} + +export interface QuizAIRecommendation { + recommendedTopics: QuizRecommendationTopic[]; + reasoning: string; +} + +export interface DailyQuizResponse { + quiz: QuizInfo; + questions: QuizQuestion[]; + aiRecommendation: QuizAIRecommendation; +} + +// ================== Submit ================== +export interface QuizAnswerSubmission { + questionId: string; + choiceIndex: number; +} + +export interface QuizSubmitRequest { + answers: QuizAnswerSubmission[]; +} + +export interface QuizAnswerResult { + questionId: string; + choiceIndex: number; + isCorrect: boolean; + correctChoiceIndex: number; + explanation: string; +} + +export interface QuizSubmitResponse { + score: number; + correctCount: number; + total: number; + answers: QuizAnswerResult[]; +} + +// ================== Service ================== +export const quizService = { + // 🟢 Get Quiz Calendar + getQuizCalendar: async (courseId: string): Promise => { + const { data } = await axiosInstance.get>( + `/courses/${courseId}/quizzes/calendar`, + ); + return data.data; + }, + + // 🟢 Get Daily Quiz + getDailyQuiz: async (courseId: string): Promise => { + const { data } = await axiosInstance.get>( + `/courses/${courseId}/quizzes/daily`, + ); + return data.data; + }, + + // 🟢 Submit Daily Quiz + submitDailyQuiz: async ( + courseId: string, + answers: QuizSubmitRequest, + ): Promise => { + const { data } = await axiosInstance.post>( + `/courses/${courseId}/quizzes/daily/submit`, + answers, + ); + return data.data; + }, +}; diff --git a/apps/web/src/services/topicsService.tsx b/apps/web/src/services/topicsService.tsx new file mode 100644 index 0000000..480b935 --- /dev/null +++ b/apps/web/src/services/topicsService.tsx @@ -0,0 +1,49 @@ +import axiosInstance from "../config/axiosInstance"; + +interface Topic { + id: string; + name: string; + content: string; + courseId: string; + order: number; + completed: boolean; + createdAt: string; + updatedAt: string; +} + +interface ApiResponse { + success: boolean; + statusCode: number; + timestamp: string; + data: T; +} + +export const topicsService = { + // 🟢 Get by ID + getTopicById: async (topicId: string): Promise => { + const { data } = await axiosInstance.get>( + `/topics/${topicId}`, + ); + return data.data; + }, + + // 🟢 Mark completed + markTopicAsCompleted: async ( + topicId: string, + ): Promise<{ success: boolean }> => { + const { data } = await axiosInstance.post< + ApiResponse<{ success: boolean }> + >(`/topics/${topicId}/completion`); + return data.data; + }, + + // 🟢 Unmark completed + unMarkTopicAsCompleted: async ( + topicId: string, + ): Promise<{ success: boolean }> => { + const { data } = await axiosInstance.post< + ApiResponse<{ success: boolean }> + >(`/topics/${topicId}/uncompletion`); + return data.data; + }, +}; diff --git a/apps/web/src/services/tracksService.tsx b/apps/web/src/services/tracksService.tsx new file mode 100644 index 0000000..f1942e1 --- /dev/null +++ b/apps/web/src/services/tracksService.tsx @@ -0,0 +1,42 @@ +import axiosInstance from "../config/axiosInstance"; + +// ✅ Track interface +export interface Track { + id: string; + name: string; + description: string; + icon: string; + createdAt: string; + updatedAt: string; +} + +export interface Pagination { + totalItems: number; + totalPages: number; + currentPage: number; + itemsPerPage: number; +} + +export interface ApiResponse { + success: boolean; + statusCode: number; + timestamp: string; + data: T; + pagination?: Pagination; +} + +export const trackService = { + // 🟢 Get tracks list + getTracksList: async (): Promise => { + const { data } = await axiosInstance.get>("/tracks"); + return data.data; + }, + + // 🟢 Get track details + getTrackDetails: async (trackId: string): Promise => { + const { data } = await axiosInstance.get>( + `/tracks/${trackId}`, + ); + return data.data; + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47e1e85..14066f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: dependencies: '@adminjs/express': specifier: ^6.1.1 - version: 6.1.1(adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8))(express-formidable@1.2.0)(express-session@1.18.2)(express@4.21.2)(tslib@2.8.1) + version: 6.1.1(adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8))(express-formidable@1.2.0)(express-session@1.18.2)(express@4.21.2)(tslib@2.8.1) '@prisma/client': specifier: 6.11.1 version: 6.11.1(prisma@6.12.0(typescript@5.8.2))(typescript@5.8.2) @@ -34,7 +34,7 @@ importers: version: 2.1.13(@tiptap/core@2.1.13(@tiptap/pm@2.1.13))(@tiptap/pm@2.1.13) adminjs: specifier: ^7.8.17 - version: 7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8) + version: 7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8) axios: specifier: ^1.11.0 version: 1.11.0 @@ -131,6 +131,9 @@ importers: '@tanstack/react-query-devtools': specifier: ^5.74.11 version: 5.83.0(@tanstack/react-query@5.83.0(react@19.1.0))(react@19.1.0) + better-auth: + specifier: ^1.2.12 + version: 1.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -140,6 +143,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 lucide-react: specifier: ^0.503.0 version: 0.503.0(react@19.1.0) @@ -176,7 +182,7 @@ importers: version: 9.32.0 '@tailwindcss/vite': specifier: ^4.1.5 - version: 4.1.11(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) + version: 4.1.11(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 @@ -186,6 +192,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 '@types/node': specifier: ^22.15.3 version: 22.16.5 @@ -197,7 +206,7 @@ importers: version: 19.1.6(@types/react@19.1.8) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) + version: 4.7.0(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) @@ -236,16 +245,16 @@ importers: version: 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.7.3) vite: specifier: ^6.3.1 - version: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + version: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) vite-plugin-svgr: specifier: ^4.3.0 - version: 4.3.0(rollup@4.40.2)(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) + version: 4.3.0(rollup@4.40.2)(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) + version: 5.1.4(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) vitest: specifier: ^3.1.2 - version: 3.2.4(@types/node@22.16.5)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3) + version: 3.2.4(@types/node@22.16.5)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) packages: @@ -2307,6 +2316,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/js-cookie@3.0.6': + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -3817,6 +3829,10 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5691,9 +5707,9 @@ snapshots: - react-is - supports-color - '@adminjs/express@6.1.1(adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8))(express-formidable@1.2.0)(express-session@1.18.2)(express@4.21.2)(tslib@2.8.1)': + '@adminjs/express@6.1.1(adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8))(express-formidable@1.2.0)(express-session@1.18.2)(express@4.21.2)(tslib@2.8.1)': dependencies: - adminjs: 7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8) + adminjs: 7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8) express: 4.21.2 express-formidable: 1.2.0 express-session: 1.18.2 @@ -6966,7 +6982,7 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@hello-pangea/dnd@16.6.0(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@hello-pangea/dnd@16.6.0(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.2 css-box-model: 1.2.1 @@ -6974,7 +6990,7 @@ snapshots: raf-schd: 4.0.3 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-redux: 8.1.3(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) + react-redux: 8.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) redux: 4.2.1 use-memo-one: 1.1.3(react@18.3.1) transitivePeerDependencies: @@ -7739,12 +7755,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.11 '@tailwindcss/oxide-win32-x64-msvc': 4.1.11 - '@tailwindcss/vite@4.1.11(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3))': + '@tailwindcss/vite@4.1.11(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2))': dependencies: '@tailwindcss/node': 4.1.11 '@tailwindcss/oxide': 4.1.11 tailwindcss: 4.1.11 - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) '@tanstack/query-core@5.83.0': {} @@ -8056,6 +8072,8 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/js-cookie@3.0.6': {} + '@types/json-schema@7.0.15': {} '@types/linkify-it@5.0.0': {} @@ -8285,7 +8303,7 @@ snapshots: '@typescript-eslint/types': 8.38.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react@4.7.0(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3))': + '@vitejs/plugin-react@4.7.0(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0) @@ -8293,7 +8311,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) transitivePeerDependencies: - supports-color @@ -8305,13 +8323,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3))': + '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -8352,7 +8370,7 @@ snapshots: acorn@8.15.0: {} - adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react@19.1.8): + adminjs@7.8.17(@types/babel__core@7.20.5)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8): dependencies: '@adminjs/design-system': 4.1.1(@babel/core@7.28.0)(@types/react@19.1.8)(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1) '@babel/core': 7.28.0 @@ -8363,7 +8381,7 @@ snapshots: '@babel/preset-react': 7.27.1(@babel/core@7.28.0) '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0) '@babel/register': 7.27.1(@babel/core@7.28.0) - '@hello-pangea/dnd': 16.6.0(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@hello-pangea/dnd': 16.6.0(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@redux-devtools/extension': 3.3.0(redux@4.2.1) '@rollup/plugin-babel': 6.0.4(@babel/core@7.28.0)(@types/babel__core@7.20.5)(rollup@4.40.2) '@rollup/plugin-commonjs': 25.0.8(rollup@4.40.2) @@ -8386,7 +8404,7 @@ snapshots: react-feather: 2.0.10(react@18.3.1) react-i18next: 12.3.1(i18next@22.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-is: 18.3.1 - react-redux: 8.1.3(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) + react-redux: 8.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) react-router: 6.30.1(react@18.3.1) react-router-dom: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) redux: 4.2.1 @@ -8570,6 +8588,24 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + better-auth@1.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@better-auth/utils': 0.2.5 + '@better-fetch/fetch': 1.1.18 + '@noble/ciphers': 0.6.0 + '@noble/hashes': 1.8.0 + '@simplewebauthn/browser': 13.1.2 + '@simplewebauthn/server': 13.1.2 + better-call: 1.0.12 + defu: 6.1.4 + jose: 5.10.0 + kysely: 0.28.2 + nanostores: 0.11.4 + zod: 4.0.17 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + better-call@1.0.12: dependencies: '@better-fetch/fetch': 1.1.18 @@ -9819,6 +9855,8 @@ snapshots: jose@5.10.0: {} + js-cookie@3.0.5: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -10604,7 +10642,7 @@ snapshots: react-fast-compare: 3.2.2 warning: 4.0.3 - react-redux@8.1.3(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1): + react-redux@8.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1): dependencies: '@babel/runtime': 7.28.2 '@types/hoist-non-react-statics': 3.3.7(@types/react@19.1.8) @@ -10615,6 +10653,7 @@ snapshots: use-sync-external-store: 1.5.0(react@18.3.1) optionalDependencies: '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) react-dom: 18.3.1(react@18.3.1) redux: 4.2.1 @@ -11427,13 +11466,13 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3): + vite-node@3.2.4(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2): dependencies: cac: 6.7.14 debug: 4.4.1(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) transitivePeerDependencies: - '@types/node' - jiti @@ -11448,29 +11487,29 @@ snapshots: - tsx - yaml - vite-plugin-svgr@4.3.0(rollup@4.40.2)(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)): + vite-plugin-svgr@4.3.0(rollup@4.40.2)(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)): dependencies: '@rollup/pluginutils': 5.1.4(rollup@4.40.2) '@svgr/core': 8.1.0(typescript@5.7.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.7.3)) - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) transitivePeerDependencies: - rollup - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)): + vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)): dependencies: debug: 4.4.0 globrex: 0.1.2 tsconfck: 3.1.5(typescript@5.7.3) optionalDependencies: - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) transitivePeerDependencies: - supports-color - typescript - vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3): + vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2): dependencies: esbuild: 0.25.4 fdir: 6.4.6(picomatch@4.0.3) @@ -11484,12 +11523,13 @@ snapshots: jiti: 2.5.1 lightningcss: 1.30.1 tsx: 4.20.3 + yaml: 1.10.2 - vitest@3.2.4(@types/node@22.16.5)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3): + vitest@3.2.4(@types/node@22.16.5)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) + '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -11507,8 +11547,8 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) - vite-node: 3.2.4(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3) + vite: 6.3.5(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) + vite-node: 3.2.4(@types/node@22.16.5)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@1.10.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.16.5 From a776b6a2ca2b3c563044b51b1ad887ef70e6a327 Mon Sep 17 00:00:00 2001 From: boshraemad Date: Wed, 3 Sep 2025 01:14:58 +0300 Subject: [PATCH 30/31] add modules --- .../apiResponse/ApiResponse.interface.ts | 9 ++ .../src/modules/courses/Course.interface.ts | 10 +++ apps/web/src/modules/notes/Notes.interface.ts | 18 ++++ .../pagination/Pagination.interface.ts | 6 ++ .../src/modules/quizes/quizes.interface.ts | 73 ++++++++++++++++ .../src/modules/topics/Topics.interface.ts | 10 +++ .../src/modules/tracks/Tracks.interface.ts | 9 ++ apps/web/src/services/coursesService.tsx | 14 +--- apps/web/src/services/notesService.tsx | 36 +------- apps/web/src/services/quizesService.tsx | 83 +------------------ apps/web/src/services/topicsService.tsx | 20 +---- apps/web/src/services/tracksService.tsx | 27 +----- 12 files changed, 145 insertions(+), 170 deletions(-) create mode 100644 apps/web/src/modules/apiResponse/ApiResponse.interface.ts create mode 100644 apps/web/src/modules/courses/Course.interface.ts create mode 100644 apps/web/src/modules/notes/Notes.interface.ts create mode 100644 apps/web/src/modules/pagination/Pagination.interface.ts create mode 100644 apps/web/src/modules/quizes/quizes.interface.ts create mode 100644 apps/web/src/modules/topics/Topics.interface.ts create mode 100644 apps/web/src/modules/tracks/Tracks.interface.ts diff --git a/apps/web/src/modules/apiResponse/ApiResponse.interface.ts b/apps/web/src/modules/apiResponse/ApiResponse.interface.ts new file mode 100644 index 0000000..e0f490c --- /dev/null +++ b/apps/web/src/modules/apiResponse/ApiResponse.interface.ts @@ -0,0 +1,9 @@ +import { Pagination } from "../pagination/pagination.interface"; + +export interface ApiResponse { + success: boolean; + statusCode: number; + timestamp: string; + data: T; + pagination?: Pagination; + } \ No newline at end of file diff --git a/apps/web/src/modules/courses/Course.interface.ts b/apps/web/src/modules/courses/Course.interface.ts new file mode 100644 index 0000000..a199163 --- /dev/null +++ b/apps/web/src/modules/courses/Course.interface.ts @@ -0,0 +1,10 @@ +import { Topic } from "../topics/topics.interface"; + +export interface Course { + id: string; + name: string; + description: string; + level: string; + topics: Topic[]; + completedPercentage: number; + } \ No newline at end of file diff --git a/apps/web/src/modules/notes/Notes.interface.ts b/apps/web/src/modules/notes/Notes.interface.ts new file mode 100644 index 0000000..db904e1 --- /dev/null +++ b/apps/web/src/modules/notes/Notes.interface.ts @@ -0,0 +1,18 @@ +export interface Note { + id: string; + title: string; + content: string; + topicId: string; + userId: string; // موجود في schema + createdAt: string; + updatedAt: string; + } + + export interface NotesListParams { + courseId?: string; + topicId?: string; + search?: string; + sort?: string; + page?: number; + limit?: number; + } \ No newline at end of file diff --git a/apps/web/src/modules/pagination/Pagination.interface.ts b/apps/web/src/modules/pagination/Pagination.interface.ts new file mode 100644 index 0000000..d3f33e5 --- /dev/null +++ b/apps/web/src/modules/pagination/Pagination.interface.ts @@ -0,0 +1,6 @@ +export interface Pagination { + totalItems: number; + totalPages: number; + currentPage: number; + itemsPerPage: number; + } \ No newline at end of file diff --git a/apps/web/src/modules/quizes/quizes.interface.ts b/apps/web/src/modules/quizes/quizes.interface.ts new file mode 100644 index 0000000..58cb7cf --- /dev/null +++ b/apps/web/src/modules/quizes/quizes.interface.ts @@ -0,0 +1,73 @@ + + // ================== Calendar ================== + export interface QuizCalendarDay { + day: number; + hasSubmission: boolean; + score: number | null; + } + + export interface QuizCalendar { + year: number; + month: number; + days: QuizCalendarDay[]; + } + + // ================== Daily Quiz ================== + export interface QuizInfo { + id: string; + userId: string; + date: string; + totalQuestions: number; + submittedAt: string | null; + score: number | null; + } + + export interface QuizQuestion { + id: string; + question: string; + choices: string[]; + difficulty: string; + topic: string; + } + + export interface QuizRecommendationTopic { + topic: string; + difficulty: string; + count: number; + } + + export interface QuizAIRecommendation { + recommendedTopics: QuizRecommendationTopic[]; + reasoning: string; + } + + export interface DailyQuizResponse { + quiz: QuizInfo; + questions: QuizQuestion[]; + aiRecommendation: QuizAIRecommendation; + } + + // ================== Submit ================== + export interface QuizAnswerSubmission { + questionId: string; + choiceIndex: number; + } + + export interface QuizSubmitRequest { + answers: QuizAnswerSubmission[]; + } + + export interface QuizAnswerResult { + questionId: string; + choiceIndex: number; + isCorrect: boolean; + correctChoiceIndex: number; + explanation: string; + } + + export interface QuizSubmitResponse { + score: number; + correctCount: number; + total: number; + answers: QuizAnswerResult[]; + } \ No newline at end of file diff --git a/apps/web/src/modules/topics/Topics.interface.ts b/apps/web/src/modules/topics/Topics.interface.ts new file mode 100644 index 0000000..0da032c --- /dev/null +++ b/apps/web/src/modules/topics/Topics.interface.ts @@ -0,0 +1,10 @@ +export interface Topic { + id: string; + name: string; + content: string; + courseId?: string; + order?: number; + completed?: boolean; + createdAt?: string; + updatedAt?: string; + } \ No newline at end of file diff --git a/apps/web/src/modules/tracks/Tracks.interface.ts b/apps/web/src/modules/tracks/Tracks.interface.ts new file mode 100644 index 0000000..9143f20 --- /dev/null +++ b/apps/web/src/modules/tracks/Tracks.interface.ts @@ -0,0 +1,9 @@ +export interface Track { + id: string; + name: string; + description: string; + icon: string; + createdAt: string; + updatedAt: string; + } + \ No newline at end of file diff --git a/apps/web/src/services/coursesService.tsx b/apps/web/src/services/coursesService.tsx index 672e7f5..3e737d1 100644 --- a/apps/web/src/services/coursesService.tsx +++ b/apps/web/src/services/coursesService.tsx @@ -1,19 +1,7 @@ import axiosInstance from "../config/axiosInstance"; +import { Course } from "../modules/courses/Course.interface"; -export interface Topic { - id: string; - name: string; - content: string; -} -export interface Course { - id: string; - name: string; - description: string; - level: string; - topics: Topic[]; - completedPercentage: number; -} export const courseService = { // 🟢 Get all courses diff --git a/apps/web/src/services/notesService.tsx b/apps/web/src/services/notesService.tsx index 07d0e85..bb809cf 100644 --- a/apps/web/src/services/notesService.tsx +++ b/apps/web/src/services/notesService.tsx @@ -1,38 +1,8 @@ import axiosInstance from "../config/axiosInstance"; +import { NotesListParams , Note } from "../modules/notes/notes.interface"; +import { Pagination } from "../modules/pagination/pagination.interface"; +import { ApiResponse } from "../modules/apiResponse/apiResponse.interface"; -export interface Note { - id: string; - title: string; - content: string; - topicId: string; - userId: string; // موجود في schema - createdAt: string; - updatedAt: string; -} - -export interface NotesListParams { - courseId?: string; - topicId?: string; - search?: string; - sort?: string; - page?: number; - limit?: number; -} - -export interface Pagination { - totalItems: number; - totalPages: number; - currentPage: number; - itemsPerPage: number; -} - -export interface ApiResponse { - success: boolean; - statusCode: number; - timestamp: string; - data: T; - pagination?: Pagination; -} export const userService = { // 🟢 Get list diff --git a/apps/web/src/services/quizesService.tsx b/apps/web/src/services/quizesService.tsx index a8033c4..6f46997 100644 --- a/apps/web/src/services/quizesService.tsx +++ b/apps/web/src/services/quizesService.tsx @@ -1,85 +1,6 @@ import axiosInstance from "../config/axiosInstance"; - -// ✅ Shared API Response Wrapper -export interface ApiResponse { - success: boolean; - statusCode: number; - timestamp: string; - data: T; -} - -// ================== Calendar ================== -export interface QuizCalendarDay { - day: number; - hasSubmission: boolean; - score: number | null; -} - -export interface QuizCalendar { - year: number; - month: number; - days: QuizCalendarDay[]; -} - -// ================== Daily Quiz ================== -export interface QuizInfo { - id: string; - userId: string; - date: string; - totalQuestions: number; - submittedAt: string | null; - score: number | null; -} - -export interface QuizQuestion { - id: string; - question: string; - choices: string[]; - difficulty: string; - topic: string; -} - -export interface QuizRecommendationTopic { - topic: string; - difficulty: string; - count: number; -} - -export interface QuizAIRecommendation { - recommendedTopics: QuizRecommendationTopic[]; - reasoning: string; -} - -export interface DailyQuizResponse { - quiz: QuizInfo; - questions: QuizQuestion[]; - aiRecommendation: QuizAIRecommendation; -} - -// ================== Submit ================== -export interface QuizAnswerSubmission { - questionId: string; - choiceIndex: number; -} - -export interface QuizSubmitRequest { - answers: QuizAnswerSubmission[]; -} - -export interface QuizAnswerResult { - questionId: string; - choiceIndex: number; - isCorrect: boolean; - correctChoiceIndex: number; - explanation: string; -} - -export interface QuizSubmitResponse { - score: number; - correctCount: number; - total: number; - answers: QuizAnswerResult[]; -} +import { QuizCalendar , DailyQuizResponse , QuizSubmitResponse , QuizSubmitRequest } from "../modules/quizes/quizes.interface"; +import { ApiResponse } from "../modules/apiResponse/apiResponse.interface"; // ================== Service ================== export const quizService = { diff --git a/apps/web/src/services/topicsService.tsx b/apps/web/src/services/topicsService.tsx index 480b935..c66ff61 100644 --- a/apps/web/src/services/topicsService.tsx +++ b/apps/web/src/services/topicsService.tsx @@ -1,22 +1,6 @@ import axiosInstance from "../config/axiosInstance"; - -interface Topic { - id: string; - name: string; - content: string; - courseId: string; - order: number; - completed: boolean; - createdAt: string; - updatedAt: string; -} - -interface ApiResponse { - success: boolean; - statusCode: number; - timestamp: string; - data: T; -} +import { Topic } from "../modules/topics/topics.interface"; +import { ApiResponse } from "../modules/apiResponse/apiResponse.interface"; export const topicsService = { // 🟢 Get by ID diff --git a/apps/web/src/services/tracksService.tsx b/apps/web/src/services/tracksService.tsx index f1942e1..f3422eb 100644 --- a/apps/web/src/services/tracksService.tsx +++ b/apps/web/src/services/tracksService.tsx @@ -1,29 +1,6 @@ import axiosInstance from "../config/axiosInstance"; - -// ✅ Track interface -export interface Track { - id: string; - name: string; - description: string; - icon: string; - createdAt: string; - updatedAt: string; -} - -export interface Pagination { - totalItems: number; - totalPages: number; - currentPage: number; - itemsPerPage: number; -} - -export interface ApiResponse { - success: boolean; - statusCode: number; - timestamp: string; - data: T; - pagination?: Pagination; -} +import { Track } from "../modules/tracks/tracks.interface"; +import { ApiResponse } from "../modules/apiResponse/apiResponse.interface"; export const trackService = { // 🟢 Get tracks list From be5175200b88041a44c3dc38c053578d16d2a1de Mon Sep 17 00:00:00 2001 From: boshraemad Date: Wed, 3 Sep 2025 03:34:30 +0300 Subject: [PATCH 31/31] import interfaces in hooks --- apps/web/src/hooks/courseHooks.ts | 4 ++-- apps/web/src/hooks/notesHooks.ts | 3 ++- apps/web/src/hooks/quizHooks.ts | 4 ++-- apps/web/src/hooks/trackHooks.ts | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/web/src/hooks/courseHooks.ts b/apps/web/src/hooks/courseHooks.ts index fbd4999..8ac8645 100644 --- a/apps/web/src/hooks/courseHooks.ts +++ b/apps/web/src/hooks/courseHooks.ts @@ -1,7 +1,7 @@ // src/hooks/courseHooks.ts import { useQuery } from "@tanstack/react-query"; -import { courseService, Course } from "../services/coursesService"; - +import { courseService } from "../services/coursesService"; +import { Course } from "../modules/courses/Course.interface"; // ================== useCoursesList ================== export const useCoursesList = () => { return useQuery({ diff --git a/apps/web/src/hooks/notesHooks.ts b/apps/web/src/hooks/notesHooks.ts index 7e16bce..13a4249 100644 --- a/apps/web/src/hooks/notesHooks.ts +++ b/apps/web/src/hooks/notesHooks.ts @@ -1,5 +1,6 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { userService, NotesListParams } from "../services/notesService"; +import { userService } from "../services/notesService"; +import { NotesListParams } from "../modules/notes/notes.interface"; // 🟢 Get Notes List export const useNotesList = (params: NotesListParams) => { diff --git a/apps/web/src/hooks/quizHooks.ts b/apps/web/src/hooks/quizHooks.ts index de8e62f..2f455fa 100644 --- a/apps/web/src/hooks/quizHooks.ts +++ b/apps/web/src/hooks/quizHooks.ts @@ -1,11 +1,11 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { quizService } from "../services/quizesService"; import { - quizService, QuizCalendar, DailyQuizResponse, QuizSubmitRequest, QuizSubmitResponse, -} from "../services/quizesService"; +} from "../modules/quizes/quizes.interface"; // ================== useQuizCalendar ================== export const useQuizCalendar = (courseId: string) => { diff --git a/apps/web/src/hooks/trackHooks.ts b/apps/web/src/hooks/trackHooks.ts index fea6177..4c01794 100644 --- a/apps/web/src/hooks/trackHooks.ts +++ b/apps/web/src/hooks/trackHooks.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; -import { trackService, Track } from "../services/tracksService"; - +import { trackService } from "../services/tracksService"; +import { Track } from "../modules/tracks/tracks.interface"; // 🔹 Hook: Get all tracks list export const useTracksList = () => { return useQuery({