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..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, { @@ -29,9 +28,10 @@ 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 8aeb33f..96f8545 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,12 +19,13 @@ "@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", "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/pnpm b/apps/api/pnpm new file mode 100644 index 0000000..e69de29 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"); 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/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"); 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 9f7ec5e..e14ca02 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,26 @@ 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 { @@ -134,6 +155,10 @@ model DailyQuiz { createdAt DateTime @default(now()) 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, quizDate]) } model User { @@ -158,6 +183,7 @@ model User { trackId String? joinedTrack Track? @relation(fields: [trackId], references: [id], onDelete: Cascade, name: "joinedTrack") userCompletions UserCompletion[] + notes Note[] // Instructors createdTracks Track[] @@ -226,3 +252,4 @@ model Jwks { @@map("jwks") } + diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index be0dc45..2e46f5d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,13 +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 { 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(ROUTES_PREFIX, router); + export default app; 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/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/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 new file mode 100644 index 0000000..902011e --- /dev/null +++ b/apps/api/src/routes/courses.ts @@ -0,0 +1,54 @@ +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 completedPercentage = + (completedTopics.length / totalTopics.length) * 100; + + res.enhancedSend(200, { + ...course, + topics: totalTopics, + completedPercentage, + }); + }, +); + +router.get( + "/:courseId/topics", + requireAuth, + validate({ params: getCourseParamsSchema, query: getCourseQuerySchema }), + async (req, res) => { + const { completed } = req.query; + + const topics = await topicService.getToltalTopics( + req.params.courseId, + completed + ? { isCompleted: completed === "true", userId: req.user!.id } + : undefined, + ); + + res.enhancedSend(200, topics); + }, +); + +export { router as coursesRouter }; diff --git a/apps/api/src/routes/notes.ts b/apps/api/src/routes/notes.ts new file mode 100644 index 0000000..aee23a4 --- /dev/null +++ b/apps/api/src/routes/notes.ts @@ -0,0 +1,65 @@ +import { Router } from "express"; +import { validate } from "../middlewares/validate.js"; +import { + createNote, + deleteNote, + getAllFilteredNotes, + getNoteById, + updateNote, +} from "../services/notes.js"; +import { + CreateNoteBodySchema, + GetAllNotesQuerySchema, + noteIdSchema, + UpdateNoteBodySchema, +} from "../schemas/notes.js"; +import { requireAuth } from "../middlewares/auth.js"; + +const router = Router(); + +router.use(requireAuth); + +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( + "/", + validate({ query: GetAllNotesQuerySchema }), + async (req, res) => { + const response = await getAllFilteredNotes(req.user!.id, req.query); + res.enhancedSend(200, response.data, response.pagination); + }, +); + +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( + "/: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.enhancedSend(200, response); + }, +); + +router.delete( + "/:noteId", + validate({ params: noteIdSchema }), + async (req, res) => { + const { noteId } = req.params; + await deleteNote(noteId, req.user!.id); + res.enhancedSend(204, undefined); + }, +); + +export { router as notesRouter }; diff --git a/apps/api/src/routes/quizzes.ts b/apps/api/src/routes/quizzes.ts index 06dbcac..cd56ff7 100644 --- a/apps/api/src/routes/quizzes.ts +++ b/apps/api/src/routes/quizzes.ts @@ -1,22 +1,89 @@ import { Router } from "express"; import { validate } from "../middlewares/validate.js"; -import { submitDailyQuizBodySchema } from "../schemas/quizzes.js"; +import { requireAuth } from "../middlewares/auth.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(); -router.get("/monthly-stats", (req, res) => { - res.send(req.url); -}); +// Calendar of submissions (month view) +router.get( + "/calendar", + requireAuth, + 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, async (req, res): Promise => { + const userId = req.session!.userId; + const today = new Date(); -router.get("/daily", (req, res) => { - res.send(req.url); + 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 }), - (req, res) => { - res.send(req.url); + 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, + }); }, ); diff --git a/apps/api/src/routes/topics.ts b/apps/api/src/routes/topics.ts new file mode 100644 index 0000000..c88ce4d --- /dev/null +++ b/apps/api/src/routes/topics.ts @@ -0,0 +1,44 @@ +import { Router } from "express"; +import { validate } from "../middlewares/validate.js"; +import { getTopicParamsSchema } from "../schemas/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 getTopic(req.params.topicId); + + res.enhancedSend(200, topic); + }, +); + +router.post( + "/:topicId/completion", + requireAuth, + validate({ params: getTopicParamsSchema }), + async (req, res) => { + 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 unmarkTopicAsCompleted(req.user!.id, req.params.topicId); + res.enhancedSend(204, undefined); + }, +); + +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..d169324 --- /dev/null +++ b/apps/api/src/routes/tracks.ts @@ -0,0 +1,32 @@ +import { Router } from "express"; +import { validate } from "../middlewares/validate.js"; +import { + getTrackQuerySchema, + getTrackParamsSchema, +} from "../schemas/tracks.js"; +import { requireAuth } from "../middlewares/auth.js"; +import { getAllTracks, getTrack } from "../services/tracks.js"; +import { getCourses } from "../services/courses.js"; + +const router = Router(); + +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; + const track = await getTrack(req.params.trackId); + + const courses = await getCourses(levelId, req.params.trackId, req.user!.id); + + res.enhancedSend(200, { ...track, courses }); + }, +); + +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..aa7ad3f --- /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.enum(["true", "false"]).optional(), +}); diff --git a/apps/api/src/schemas/notes.ts b/apps/api/src/schemas/notes.ts new file mode 100644 index 0000000..89a341a --- /dev/null +++ b/apps/api/src/schemas/notes.ts @@ -0,0 +1,75 @@ +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.", + }), + topicId: z.string().cuid({ message: "topicId must be a valid CUID." }), +}); + +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 = new Set([ + "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.has(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), +}); + +export const noteIdSchema = z.object({ + noteId: z.string().trim().cuid({ message: "id must be a valid CUID." }), +}); + +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 1e6e4e9..7cad5ea 100644 --- a/apps/api/src/schemas/quizzes.ts +++ b/apps/api/src/schemas/quizzes.ts @@ -1,10 +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(), - answer: z.string().optional(), - }), - ), + answers: z + .array( + z.object({ + questionId: z.string().uuid(), + choiceIndex: z.number().int().min(0), + }), + ) + .min(1), }); 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..723a463 --- /dev/null +++ b/apps/api/src/services/courses.ts @@ -0,0 +1,74 @@ +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 | undefined, + trackId: string, + userId: string, +) => { + const courses = await prisma.course.findMany({ + where: { + trackId, + levelId, + }, + orderBy: { + order: "asc", + }, + include: { + topics: { + select: { + userCompletions: { + where: { + userId, + }, + }, + }, + }, + }, + }); + + 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/notes.ts b/apps/api/src/services/notes.ts new file mode 100644 index 0000000..3b6cd80 --- /dev/null +++ b/apps/api/src/services/notes.ts @@ -0,0 +1,147 @@ +import { NotFoundError } from "../errors/not-found.js"; +import { Note, Prisma } from "../generated/prisma/client.js"; +import { prisma } from "../lib/prisma.js"; +import { + CreateNoteBodyType, + GetAllNotesQueryType, + UpdateNoteServiceType, +} from "../schemas/notes.js"; +import { Pagination } from "../schemas/pagination.js"; + +interface PaginatedNotesResult { + pagination: Pagination; + data: Note[]; +} + +export async function createNote( + input: CreateNoteBodyType, + userId: string, +): Promise { + const topic = await prisma.topic.findUnique({ + where: { id: input.topicId }, + select: { courseId: true }, + }); + + if (!topic) { + throw new NotFoundError(); + } + + const createdNote = await prisma.note.create({ + data: { + title: input.title, + content: input.content, + topicId: input.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: { + totalItems: totalCount, + totalPages: Math.ceil(totalCount / limit), + currentPage: page, + itemsPerPage: limit, + }, + data: notes, + }; +} diff --git a/apps/api/src/services/questions.ts b/apps/api/src/services/questions.ts new file mode 100644 index 0000000..0cd0cc5 --- /dev/null +++ b/apps/api/src/services/questions.ts @@ -0,0 +1,25 @@ +import { prisma } from "../lib/prisma.js"; + +export const fetchQuestionsByRecommendation = async ( + aiRecommendation: { topics: { recommendations: { level: number }[] }[] }, + totalQuestions: number, +) => { + const levelsToFetch = aiRecommendation.topics.flatMap((topic) => + topic.recommendations.map((rec) => rec.level), + ); + + return prisma.question.findMany({ + where: { + topics: { + some: { + course: { + level: { + title: { in: levelsToFetch.map((lvl: number) => `Level ${lvl}`) }, + }, + }, + }, + }, + }, + take: totalQuestions, + }); +}; diff --git a/apps/api/src/services/quizzes.ts b/apps/api/src/services/quizzes.ts index 2288cc0..c52c5a8 100644 --- a/apps/api/src/services/quizzes.ts +++ b/apps/api/src/services/quizzes.ts @@ -1,3 +1,182 @@ -export const generateNewQuiz = () => { - console.log("Generating a new quiz..."); +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 index 63a8bd7..bc2463b 100644 --- a/apps/api/src/services/recommendations.ts +++ b/apps/api/src/services/recommendations.ts @@ -1,3 +1,13 @@ -export const getRecommendations = () => { - console.log("Getting recommendations..."); +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 new file mode 100644 index 0000000..cafe473 --- /dev/null +++ b/apps/api/src/services/topics.ts @@ -0,0 +1,70 @@ +import { Prisma } from "../generated/prisma/client.js"; +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 markTopicAsCompleted = async (userId: string, topicId: string) => { + return await prisma.userCompletion.create({ + data: { + userId, + topicId, + }, + }); +}; +export const unmarkTopicAsCompleted = async ( + userId: string, + topicId: string, +) => { + return await prisma.userCompletion.delete({ + where: { + userId_topicId: { userId, topicId }, + }, + }); +}; + +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: whereClause, + 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; +}; 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..8ac8645 --- /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 } from "../services/coursesService"; +import { Course } from "../modules/courses/Course.interface"; +// ================== 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..13a4249 --- /dev/null +++ b/apps/web/src/hooks/notesHooks.ts @@ -0,0 +1,60 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { userService } from "../services/notesService"; +import { NotesListParams } from "../modules/notes/notes.interface"; + +// 🟢 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..2f455fa --- /dev/null +++ b/apps/web/src/hooks/quizHooks.ts @@ -0,0 +1,41 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { quizService } from "../services/quizesService"; +import { + QuizCalendar, + DailyQuizResponse, + QuizSubmitRequest, + QuizSubmitResponse, +} from "../modules/quizes/quizes.interface"; + +// ================== 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..4c01794 --- /dev/null +++ b/apps/web/src/hooks/trackHooks.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import { trackService } from "../services/tracksService"; +import { Track } from "../modules/tracks/tracks.interface"; +// 🔹 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/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/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..3e737d1 --- /dev/null +++ b/apps/web/src/services/coursesService.tsx @@ -0,0 +1,30 @@ +import axiosInstance from "../config/axiosInstance"; +import { Course } from "../modules/courses/Course.interface"; + + + +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..bb809cf --- /dev/null +++ b/apps/web/src/services/notesService.tsx @@ -0,0 +1,67 @@ +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 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..6f46997 --- /dev/null +++ b/apps/web/src/services/quizesService.tsx @@ -0,0 +1,34 @@ +import axiosInstance from "../config/axiosInstance"; +import { QuizCalendar , DailyQuizResponse , QuizSubmitResponse , QuizSubmitRequest } from "../modules/quizes/quizes.interface"; +import { ApiResponse } from "../modules/apiResponse/apiResponse.interface"; + +// ================== 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..c66ff61 --- /dev/null +++ b/apps/web/src/services/topicsService.tsx @@ -0,0 +1,33 @@ +import axiosInstance from "../config/axiosInstance"; +import { Topic } from "../modules/topics/topics.interface"; +import { ApiResponse } from "../modules/apiResponse/apiResponse.interface"; + +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..f3422eb --- /dev/null +++ b/apps/web/src/services/tracksService.tsx @@ -0,0 +1,19 @@ +import axiosInstance from "../config/axiosInstance"; +import { Track } from "../modules/tracks/tracks.interface"; +import { ApiResponse } from "../modules/apiResponse/apiResponse.interface"; + +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 2d1a243..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,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) @@ -51,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 @@ -128,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 @@ -137,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) @@ -173,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 @@ -183,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 @@ -194,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) @@ -233,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: @@ -2304,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==} @@ -3814,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==} @@ -5636,8 +5655,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: @@ -5688,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 @@ -6963,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 @@ -6971,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: @@ -7736,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': {} @@ -8053,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': {} @@ -8282,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) @@ -8290,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 @@ -8302,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: @@ -8349,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 @@ -8360,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) @@ -8383,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 @@ -8562,11 +8583,29 @@ 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) + 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 @@ -9816,6 +9855,8 @@ snapshots: jose@5.10.0: {} + js-cookie@3.0.5: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -10601,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) @@ -10612,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 @@ -11424,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 @@ -11445,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) @@ -11481,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 @@ -11504,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 @@ -11643,4 +11686,4 @@ snapshots: zod@3.25.76: {} - zod@4.0.10: {} + zod@4.0.17: {}